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
This commit is contained in:
limeng 2026-10-14 13:47:00 +08:00
parent 1684902863
commit 71652c5a64
60 changed files with 8990 additions and 139 deletions

View File

@ -0,0 +1,98 @@
<?php
namespace App\Business;
use App\Models\Business\CourierOnboarding;
use App\Support\CourierOnboardingService;
use Illuminate\Pagination\LengthAwarePaginator;
/**
* 「快递入驻」列表页数据:状态统计、筛选和分页。
*/
class CourierOnboardingPageData
{
/** 列表页状态分组,键为页面筛选值。 */
public const STATUS_GROUPS = [
'pending' => ['label' => '待处理', 'statuses' => [CourierOnboarding::STATUS_SUBMITTED, CourierOnboarding::STATUS_FIRST_APPROVED, CourierOnboarding::STATUS_APPROVED]],
'filling' => ['label' => '待快递填写', 'statuses' => [CourierOnboarding::STATUS_INVITED, CourierOnboarding::STATUS_RETURNED]],
'activated' => ['label' => '已开通', 'statuses' => [CourierOnboarding::STATUS_ACTIVATED]],
'closed' => ['label' => '已关闭', 'statuses' => [CourierOnboarding::STATUS_REJECTED, CourierOnboarding::STATUS_CANCELLED]],
'all' => ['label' => '全部', 'statuses' => []],
];
public function __construct(private readonly CourierOnboardingService $service)
{
}
public function page(): array
{
$group = (string) request()->query('group', 'pending');
$group = array_key_exists($group, self::STATUS_GROUPS) ? $group : 'pending';
$search = trim((string) request()->query('search', ''));
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$data = [
'view' => 'business.pages.courier-payables.courier-onboarding',
'onboardingReady' => $this->service->ready(),
'onboardingGroup' => $group,
'onboardingSearch' => $search,
'onboardingGroups' => self::STATUS_GROUPS,
'onboardingCounts' => [],
'onboardingRows' => new LengthAwarePaginator([], 0, $limit),
];
if (! $data['onboardingReady']) {
return $data;
}
$counts = $this->service->statusCounts();
$data['onboardingCounts'] = collect(self::STATUS_GROUPS)
->map(static fn (array $config): int => $config['statuses'] === []
? array_sum($counts)
: array_sum(array_intersect_key($counts, array_flip($config['statuses']))))
->all();
$query = CourierOnboarding::query()->withCount('files');
$statuses = self::STATUS_GROUPS[$group]['statuses'];
if ($statuses !== []) {
$query->whereIn('status', $statuses);
}
if ($search !== '') {
$like = '%' . addcslashes($search, '%_\\') . '%';
$query->where(static function ($builder) use ($like): void {
foreach (['number', 'invite_label', 'company_full_name', 'network_name', 'courier_name', 'contact_name', 'contact_phone'] as $column) {
$builder->orWhere($column, 'like', $like);
}
});
}
$data['onboardingRows'] = $query->latest('updated_at')->latest('id')
->paginate($limit)
->withQueryString();
return $data;
}
/**
* 快递资料页「入驻快递」页签:已开通的入驻快递,作为新快递的主数据。
*/
public function activated(int $page, int $limit): LengthAwarePaginator
{
if (! $this->service->ready()) {
return new LengthAwarePaginator([], 0, $limit, $page);
}
$search = trim((string) request()->query('search', ''));
$query = CourierOnboarding::query()->where('status', CourierOnboarding::STATUS_ACTIVATED);
if ($search !== '') {
$like = '%' . addcslashes($search, '%_\\') . '%';
$query->where(static function ($builder) use ($like): void {
foreach (['courier_name', 'company_full_name', 'network_name', 'contact_name'] as $column) {
$builder->orWhere($column, 'like', $like);
}
});
}
return $query->latest('activated_at')->paginate($limit, ['*'], 'page', $page)->withQueryString();
}
}

View File

@ -0,0 +1,314 @@
<?php
namespace App\Business;
use App\Models\Business\CourierOnboarding;
use App\Models\Business\CourierOnboardingFile;
use App\Support\CourierRequoteService;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
use Throwable;
/**
* 快递总览:工作平台(钉钉流程 → wp_express)和财务系统快递入驻按快递合成一行。
*
* 字段各有归属:快递代码、发货账号、ERP账户ID 归工作平台;主体、对公/对私账户、证照、报价、签署归财务系统。
* 两边先按入驻里关联的「工作平台快递代码」对,没关联的按系统快递名称对。
*/
final class CourierOverviewData
{
public const SOURCES = [
'erp_pending' => '待 ERP 开通',
'both' => '入驻 + 工作平台',
'onboarding' => '只有入驻',
'platform' => '只在工作平台(缺入驻资料)',
'mismatch' => '账户对不上',
];
private ?Collection $onboardingCache = null;
public function __construct(
private readonly CourierInformationMirrorRepository $mirror,
private readonly CourierRequoteService $requotes,
) {
}
/**
* @return array{rows:LengthAwarePaginator,counts:array<string,int>,platform_ready:bool}
*/
public function page(array $filters, int $page, int $perPage): array
{
$all = $this->rows();
$platformReady = $all['platform_ready'];
$rows = collect($all['rows']);
$counts = [
'all' => $rows->count(),
'both' => $rows->where('source', 'both')->count(),
'platform' => $rows->where('source', 'platform')->count(),
'onboarding' => $rows->where('source', 'onboarding')->count(),
'mismatch' => $rows->where('account_status', 'mismatch')->count(),
'erp_pending' => $rows->where('erp_status', 'pending')->count(),
];
$source = (string) ($filters['source'] ?? '');
if ($source === 'mismatch') {
$rows = $rows->where('account_status', 'mismatch');
} elseif ($source === 'erp_pending') {
$rows = $rows->where('erp_status', 'pending');
} elseif (array_key_exists($source, self::SOURCES)) {
$rows = $rows->where('source', $source);
}
$keyword = trim((string) ($filters['search'] ?? ''));
if ($keyword !== '') {
$rows = $rows->filter(static fn (array $row): bool => str_contains(
mb_strtolower($row['name'] . ' ' . ($row['expcode'] ?? '') . ' ' . ($row['onboarding']?->company_full_name ?? '')),
mb_strtolower($keyword),
));
}
$rows = $rows->values();
return [
'rows' => new LengthAwarePaginator(
$rows->forPage($page, $perPage)->values(),
$rows->count(),
$perPage,
$page,
['path' => request()->url(), 'query' => request()->except('page')],
),
'counts' => $counts,
'platform_ready' => $platformReady,
];
}
/**
* @return array{rows:list<array<string,mixed>>,platform_ready:bool}
*/
public function rows(): array
{
try {
$platform = $this->mirror->overviewSource();
} catch (Throwable $exception) {
report($exception);
$platform = null;
}
$onboardings = Schema::hasTable('business_courier_onboardings')
? CourierOnboarding::query()
->with('files')
->whereNotIn('status', [CourierOnboarding::STATUS_CANCELLED, CourierOnboarding::STATUS_REJECTED])
->latest('id')
->get()
: collect();
$byCode = $onboardings->filter(static fn (CourierOnboarding $o): bool => filled($o->workplatform_expcode))
->keyBy(static fn (CourierOnboarding $o): string => (string) $o->workplatform_expcode);
$byName = $onboardings->filter(static fn (CourierOnboarding $o): bool => filled($o->courier_name))
->keyBy(static fn (CourierOnboarding $o): string => CourierOnboarding::normalizeName($o->courier_name));
$used = [];
$rows = [];
foreach ($platform['express'] ?? [] as $express) {
$code = (string) $express->expcode;
$onboarding = $byCode->get($code) ?? $byName->get(CourierOnboarding::normalizeName($express->expname));
if ($onboarding !== null && isset($used[$onboarding->id])) {
$onboarding = null;
}
if ($onboarding !== null) {
$used[$onboarding->id] = true;
}
$rows[] = $this->row($onboarding, $express, $platform['accounts'][$code] ?? []);
}
foreach ($onboardings as $onboarding) {
if (! isset($used[$onboarding->id])) {
$rows[] = $this->row($onboarding, null, []);
}
}
usort($rows, static fn (array $a, array $b): int => [$a['sort'], $a['name']] <=> [$b['sort'], $b['name']]);
return ['rows' => $rows, 'platform_ready' => $platform !== null];
}
/**
* @param list<object> $accounts
* @return array<string,mixed>
*/
private function row(?CourierOnboarding $onboarding, ?object $express, array $accounts): array
{
$source = $onboarding && $express ? 'both' : ($express ? 'platform' : 'onboarding');
$platformNumbers = collect($accounts)
->map(static fn ($account): string => preg_replace('/\D+/', '', (string) ($account->account ?? '')))
->filter()
->values();
$onboardingNumbers = collect([$onboarding?->bank_account_number, $onboarding?->private_account_number])
->map(static fn ($number): string => preg_replace('/\D+/', '', (string) $number))
->filter()
->values();
$accountStatus = match (true) {
$source !== 'both' => null,
$platformNumbers->isEmpty() => 'platform_missing',
$onboardingNumbers->isEmpty() => 'onboarding_missing',
$onboardingNumbers->diff($platformNumbers)->isEmpty() => 'match',
$onboardingNumbers->intersect($platformNumbers)->isNotEmpty() => 'partial',
default => 'mismatch',
};
$required = $onboarding ? CourierOnboardingFile::requiredKindsFor($onboarding->entity_type) : [];
$kinds = $onboarding ? $onboarding->files->pluck('kind')->unique()->all() : [];
$missingFiles = array_values(array_diff($required, $kinds));
$effective = $onboarding && filled($onboarding->courier_name) && Schema::hasTable('business_courier_quote_versions')
? $this->requotes->effectiveVersion((string) $onboarding->courier_name)
: null;
// ERP 开通:入驻开通后等钉钉「新建快递」流程回填快递代码;工作平台老快递视为已开通。
$erpStatus = match (true) {
$onboarding === null => 'opened',
$onboarding->status !== CourierOnboarding::STATUS_ACTIVATED => 'na',
$onboarding->erp_opened_at !== null || $express !== null => 'opened',
default => 'pending',
};
$erpAccountId = $onboarding?->erp_account_id
?: collect($accounts)->pluck('erpAccountId')->filter()->unique()->implode(' / ');
return [
'erp_status' => $erpStatus,
'erp_account_id' => $erpAccountId ?: null,
'erp_source' => $onboarding?->erpSourceLabel() ?: ($express ? '工作平台' : null),
'name' => (string) ($express->expname ?? $onboarding?->courier_name ?? $onboarding?->company_full_name ?? ''),
'expcode' => $express?->expcode ?? $onboarding?->workplatform_expcode,
'source' => $source,
'sort' => match (true) {
$erpStatus === 'pending' => 0,
$accountStatus === 'mismatch' => 1,
$source === 'onboarding' => 2,
$source === 'both' => 3,
default => 4,
},
'platform' => $express,
'platform_accounts' => $accounts,
'onboarding' => $onboarding,
'account_status' => $accountStatus,
'missing_files' => array_map(static fn (string $kind): string => CourierOnboardingFile::labelFor($kind, $onboarding?->entity_type), $missingFiles),
'effective_version' => $effective?->version_code,
'linked_by' => $onboarding && $express ? ((string) $onboarding->workplatform_expcode === (string) $express->expcode ? 'code' : 'name') : null,
];
}
/**
* 「快递资料」表格右侧接上的入驻信息:本页工作平台快递按快递代码 / 系统快递名称对上的入驻,
* 以及还没进工作平台(待 ERP 开通、或停用工作平台后新入驻)的快递,放在第一页最前面。
*
* @param list<object> $expressRows 本页工作平台快递
* @return array{matches:array<string,array<string,mixed>>,extra:list<array<string,mixed>>}
*/
public function expressAppend(array $expressRows, int $page, string $nameKeyword = '', string $codeFilter = ''): array
{
$onboardings = $this->onboardings();
if ($onboardings->isEmpty()) {
return ['matches' => [], 'extra' => []];
}
$byCode = $onboardings->filter(static fn (CourierOnboarding $o): bool => filled($o->workplatform_expcode))
->keyBy(static fn (CourierOnboarding $o): string => (string) $o->workplatform_expcode);
$byName = $onboardings->filter(static fn (CourierOnboarding $o): bool => filled($o->courier_name))
->keyBy(static fn (CourierOnboarding $o): string => CourierOnboarding::normalizeName($o->courier_name));
$matches = [];
foreach ($expressRows as $express) {
$onboarding = $byCode->get((string) ($express->expcode ?? '')) ?? $byName->get(CourierOnboarding::normalizeName($express->expname ?? ''));
if ($onboarding !== null) {
$matches[(string) ($express->Id ?? $express->expcode)] = $this->onboardingInfo($onboarding, true);
}
}
$extra = [];
if ($page === 1) {
try {
$platform = $this->mirror->overviewSource();
} catch (Throwable) {
$platform = null;
}
$codes = collect($platform['express'] ?? [])->pluck('expcode')->map(static fn ($code): string => (string) $code)->flip();
$names = collect($platform['express'] ?? [])->pluck('expname')->map(static fn ($name): string => CourierOnboarding::normalizeName($name))->flip();
foreach ($onboardings as $onboarding) {
$inPlatform = (filled($onboarding->workplatform_expcode) && $codes->has((string) $onboarding->workplatform_expcode))
|| (filled($onboarding->courier_name) && $names->has(CourierOnboarding::normalizeName($onboarding->courier_name)));
if ($inPlatform) {
continue;
}
if ($codeFilter !== '' && strcasecmp((string) $onboarding->workplatform_expcode, $codeFilter) !== 0) {
continue;
}
if ($nameKeyword !== '' && ! str_contains(mb_strtolower((string) $onboarding->courier_name . ' ' . $onboarding->company_full_name), mb_strtolower($nameKeyword))) {
continue;
}
$extra[] = $this->onboardingInfo($onboarding, false);
}
}
return ['matches' => $matches, 'extra' => $extra];
}
/** 快递资料里选中的一行对应的入驻(先按快递公司代码,再按系统快递名称)。 */
public function infoFor(?string $code, ?string $name): ?array
{
$onboardings = $this->onboardings();
$onboarding = (filled($code) ? $onboardings->first(static fn (CourierOnboarding $o): bool => strcasecmp((string) $o->workplatform_expcode, (string) $code) === 0) : null)
?? (filled($name) ? $onboardings->first(static fn (CourierOnboarding $o): bool => CourierOnboarding::normalizeName($o->courier_name) === CourierOnboarding::normalizeName($name)) : null);
return $onboarding !== null ? $this->onboardingInfo($onboarding, true) : null;
}
/** @return Collection<int,CourierOnboarding> */
private function onboardings(): Collection
{
return $this->onboardingCache ??= Schema::hasTable('business_courier_onboardings')
? CourierOnboarding::query()
->with('files')
->whereNotIn('status', [CourierOnboarding::STATUS_CANCELLED, CourierOnboarding::STATUS_REJECTED])
->latest('id')
->get()
: collect();
}
/** @return array<string,mixed> */
private function onboardingInfo(CourierOnboarding $onboarding, bool $inPlatform): array
{
$required = CourierOnboardingFile::requiredKindsFor($onboarding->entity_type);
$missing = array_values(array_diff($required, $onboarding->files->pluck('kind')->unique()->all()));
$effective = filled($onboarding->courier_name) && Schema::hasTable('business_courier_quote_versions')
? $this->requotes->effectiveVersion((string) $onboarding->courier_name)
: null;
// 合同起止:协议签署里「合作合同」已签署的那份(最新一份)上填的合作期限。
$contract = app(\App\Support\CourierOnboardingService::class)->agreementSignatures($onboarding)
->first(static fn (\App\Models\Business\CourierAgreementSignature $signature): bool => $signature->document_type === 'cooperation-contract'
&& in_array($signature->status, [\App\Models\Business\CourierAgreementSignature::STATUS_COMPLETED, \App\Models\Business\CourierAgreementSignature::STATUS_ARCHIVED], true));
$contractPayload = $contract !== null && is_array($contract->payload) ? $contract->payload : [];
return [
'contract_start' => $contractPayload['cooperation_start_date'] ?? null,
'contract_end' => $contractPayload['cooperation_end_date'] ?? null,
'onboarding' => $onboarding,
'erp_status' => match (true) {
$onboarding->status !== CourierOnboarding::STATUS_ACTIVATED => 'na',
$inPlatform || $onboarding->erp_opened_at !== null => 'opened',
default => 'pending',
},
'checks' => $onboarding->accountChecks(),
'missing_files' => array_map(static fn (string $kind): string => CourierOnboardingFile::labelFor($kind, $onboarding->entity_type), $missing),
'effective_version' => $effective?->version_code,
];
}
public static function mask(?string $number): string
{
$digits = preg_replace('/\s+/', '', (string) $number);
return $digits === '' ? '' : '尾号 ' . mb_substr($digits, -4);
}
}

View File

@ -0,0 +1,343 @@
<?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;
}
}

View File

@ -100,6 +100,12 @@ class Handler extends ExceptionHandler
*/
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);
}

View File

@ -0,0 +1,339 @@
<?php
namespace App\Http\Controllers\Business;
use App\Models\Business\CourierOnboarding;
use App\Models\Business\CourierOnboardingEvent;
use App\Models\Business\CourierOnboardingFile;
use App\Support\CourierOnboardingService;
use App\Support\QrCode;
use App\Utilities\SystemAccess;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class CourierOnboardings extends Controller
{
public function __construct(private readonly CourierOnboardingService $service)
{
}
public function store(Request $request): RedirectResponse
{
$this->ensureReady();
$data = $request->validate([
'courier_name' => ['required', 'string', 'max:100'],
'invite_label' => ['nullable', 'string', 'max:100'],
'valid_days' => ['nullable', 'integer', 'min:1', 'max:180'],
'workplatform_expcode' => ['required', 'string', 'max:64', 'regex:/^[0-9A-Za-z_\-]+$/'],
], [
'courier_name.required' => '请先定好系统快递名称(需与报价名、发货数据里的快递名称一致)。',
'workplatform_expcode.required' => '请填写快递公司代码(与快递资料「快递公司代码」一致)。',
'workplatform_expcode.regex' => '快递公司代码只能包含字母、数字、下划线和短横线。',
]);
$code = strtoupper(trim((string) $data['workplatform_expcode']));
$taken = CourierOnboarding::query()
->where('workplatform_expcode', $code)
->whereNotIn('status', [CourierOnboarding::STATUS_REJECTED, CourierOnboarding::STATUS_CANCELLED])
->exists();
if ($taken) {
throw ValidationException::withMessages(['workplatform_expcode' => "快递公司代码「{$code}」已被其他入驻申请使用。"]);
}
$onboarding = $this->service->invite(
$data['invite_label'] ?? null,
(int) ($data['valid_days'] ?? CourierOnboardingService::DEFAULT_VALID_DAYS),
(int) user_id(),
$data['courier_name'],
);
$onboarding->update(['workplatform_expcode' => $code]);
return redirect()->route('business.courier-onboardings.show', $onboarding)
->with('success', '入驻二维码已生成,发给快递方扫码填写即可。');
}
public function show(CourierOnboarding $onboarding): View
{
$this->ensureReady();
try {
app(\App\Support\CourierQuoteSignatureService::class)->refreshPending();
} catch (\Throwable $exception) {
report($exception);
}
$onboarding->load(['files', 'events.actor', 'quoteVersion.signature']);
return view('business.pages.courier-payables.courier-onboarding-show', [
'onboarding' => $onboarding,
'formUrl' => $this->formUrl($onboarding),
'signatures' => $this->service->agreementSignatures($onboarding),
'signedAgreement' => $this->service->signedAgreement($onboarding),
'quoteSignature' => $onboarding->quoteSignature(),
'esignReady' => $this->service->esignReady(),
'canFinalReview' => $this->isAdmin() && (
(int) $onboarding->first_reviewed_by !== (int) user_id() || config('courier_onboarding.allow_same_reviewer')
),
'isAdmin' => $this->isAdmin(),
'quoteVersions' => $onboarding->status === CourierOnboarding::STATUS_ACTIVATED ? $this->requotes()->versions($onboarding) : collect(),
]);
}
/** 后台变价页:复用入驻报价填写区,以最新版本(或已退回的变价单)为底稿。 */
public function requote(CourierOnboarding $onboarding): View|RedirectResponse
{
$this->ensureReady();
$quoteManagement = route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management']);
if ($onboarding->status !== CourierOnboarding::STATUS_ACTIVATED) {
return redirect()->to($quoteManagement)->with('success', '这家快递还没开通,开通后才能变价。');
}
$requotes = $this->requotes();
$open = $requotes->openChange($onboarding);
if ($open !== null && $open->status === \App\Models\Business\CourierQuoteChange::STATUS_PENDING_REVIEW) {
return redirect()->route('business.courier-quote-changes.show', $open)
->with('success', "已有待审核的变价单 {$open->number},审核完成后再发起新的变价。");
}
$version = $requotes->latestVersion($onboarding);
$payload = $open?->quote_payload ?? $requotes->currentPayload($onboarding);
if ($open === null) {
// 新变价默认从明天起执行。
$payload['quote_effective_date'] = today()->addDay()->toDateString();
}
return view('onboarding.courier-form', [
'onboarding' => $requotes->draftModel($onboarding, $payload),
'token' => '',
'provinces' => CourierOnboarding::PROVINCES,
'fileKinds' => CourierOnboardingFile::KINDS,
'requiredKinds' => CourierOnboardingFile::REQUIRED_KINDS,
'personKinds' => CourierOnboardingFile::PERSON_KINDS,
'personRequiredKinds' => CourierOnboardingFile::PERSON_REQUIRED_KINDS,
'requote' => [
'action' => route('business.courier-onboardings.requote.store', $onboarding),
'back' => $quoteManagement,
'courier' => $onboarding->courier_name,
'signer' => trim(($onboarding->isPerson() ? '' : $onboarding->company_full_name . ' · ') . $onboarding->esign_operator_name . ' ' . $onboarding->esign_account),
'version' => $version,
'change' => $open,
'summary' => old('summary', $open?->summary),
],
]);
}
public function storeRequote(Request $request, CourierOnboarding $onboarding): RedirectResponse
{
$this->ensureReady();
$data = $request->validate([
...\App\Http\Controllers\Onboarding\CourierOnboardingForms::quoteRules(),
'summary' => ['required', 'string', 'min:2', 'max:2000'],
'quote_end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:quote_effective_date'],
], [
'summary.required' => '请写一下这次变价的原因或依据,审核和对账时要看。',
'quote_end_date.after_or_equal' => '结束日期不能早于执行日期。',
]);
$change = $this->requotes()->submit($onboarding, $data, (int) user_id());
return redirect()->route('business.courier-quote-changes.show', $change)
->with('success', "变价单 {$change->number} 已提交审核。审核通过后会自动推送到快递的 e签宝签署。");
}
private function requotes(): \App\Support\CourierRequoteService
{
return app(\App\Support\CourierRequoteService::class);
}
public function qr(Request $request, CourierOnboarding $onboarding): Response
{
$this->ensureReady();
$svg = QrCode::svg($this->formUrl($onboarding), 8);
$headers = ['Content-Type' => 'image/svg+xml; charset=utf-8', 'Cache-Control' => 'private, no-store'];
if ($request->boolean('download')) {
$headers['Content-Disposition'] = 'attachment; filename="courier-onboarding-' . $onboarding->number . '.svg"';
}
return response($svg, 200, $headers);
}
public function file(CourierOnboarding $onboarding, CourierOnboardingFile $file): StreamedResponse
{
$this->ensureReady();
abort_unless((int) $file->onboarding_id === (int) $onboarding->id, 404);
abort_unless(Storage::disk('uploads')->exists($file->path), 404, '附件不存在。');
return Storage::disk('uploads')->response($file->path, $file->original_name, [
'Content-Type' => $file->mime ?: 'application/octet-stream',
], request()->boolean('download') ? 'attachment' : 'inline');
}
public function firstApprove(Request $request, CourierOnboarding $onboarding): RedirectResponse
{
$note = $request->validate(['note' => ['nullable', 'string', 'max:1000']])['note'] ?? null;
$this->service->firstApprove($onboarding, (int) user_id(), $note);
return $this->back($onboarding, '初审通过,已转财务复审。');
}
public function returnForChanges(Request $request, CourierOnboarding $onboarding): RedirectResponse
{
$reason = $request->validate(['reason' => ['required', 'string', 'max:1000']], ['reason.required' => '请写明需要快递方补充或修改的内容。'])['reason'];
$this->service->returnForChanges($onboarding, (int) user_id(), $reason);
return $this->back($onboarding, '已退回补件,快递方用原二维码即可修改后重新提交。');
}
public function reject(Request $request, CourierOnboarding $onboarding): RedirectResponse
{
$reason = $request->validate(['reason' => ['required', 'string', 'max:1000']], ['reason.required' => '请填写不予合作的原因。'])['reason'];
$this->service->reject($onboarding, (int) user_id(), $reason);
return $this->back($onboarding, '已标记为不予合作。');
}
public function cancel(CourierOnboarding $onboarding): RedirectResponse
{
$this->service->cancel($onboarding, (int) user_id());
return $this->back($onboarding, '邀请已作废,原二维码不能再打开。');
}
public function extend(CourierOnboarding $onboarding): RedirectResponse
{
$this->service->extend($onboarding, (int) user_id());
return $this->back($onboarding, '链接有效期已延长 30 天。');
}
public function updateInternal(Request $request, CourierOnboarding $onboarding): RedirectResponse
{
$data = $request->validate([
'courier_name' => ['nullable', 'string', 'max:100'],
'reconciler' => ['nullable', 'string', 'max:100'],
]);
$this->service->updateInternal($onboarding, $data, (int) user_id());
return $this->back($onboarding, '内部信息已保存。');
}
/**
* 手动补录 ERP 开通信息(钉钉流程同步不到、或名称对不上时用)。
* 快递资料按快递代码把入驻和工作平台老数据合成一行。
*/
public function erpOpening(Request $request, CourierOnboarding $onboarding): RedirectResponse
{
$data = $request->validate([
'workplatform_expcode' => ['nullable', 'string', 'max:64'],
'erp_account_id' => ['nullable', 'string', 'max:64'],
]);
// 快递公司代码在发码时已必填;这里留空表示沿用原代码。填了代码或 ERP账户ID 视为已开通,都空则恢复「待 ERP 开通」。
$code = filled($data['workplatform_expcode'] ?? null) ? strtoupper(trim((string) $data['workplatform_expcode'])) : null;
$accountId = filled($data['erp_account_id'] ?? null) ? trim((string) $data['erp_account_id']) : null;
$opened = $code !== null || $accountId !== null;
$onboarding->update([
'workplatform_expcode' => $code ?? $onboarding->workplatform_expcode,
'erp_account_id' => $accountId,
'erp_opened_at' => $opened ? ($onboarding->erp_opened_at ?? now()) : null,
'erp_opened_source' => $opened ? 'manual' : null,
'updated_by' => (int) user_id(),
]);
return $this->back($onboarding, $opened ? 'ERP 开通信息已保存(快递公司代码 ' . $onboarding->workplatform_expcode . ')。' : '已清空 ERP 开通信息,恢复为「待 ERP 开通」。');
}
public function finalApprove(Request $request, CourierOnboarding $onboarding): RedirectResponse
{
$this->ensureAdmin();
$data = $request->validate([
'courier_name' => [$onboarding->courier_name ? 'nullable' : 'required', 'string', 'max:100'],
'reconciler' => ['nullable', 'string', 'max:100'],
'note' => ['nullable', 'string', 'max:1000'],
], ['courier_name.required' => '请填写系统快递名称(需与报价名、发货数据里的快递名称完全一致)。']);
$this->service->finalApprove($onboarding, $data, (int) user_id());
if (! $onboarding->quote_version_id) {
return $this->back($onboarding, '财务复审通过。快递没有填写报价明细,请在报价管理补录后再发起报价确认。');
}
if (! $this->service->esignReady()) {
return $this->back($onboarding, '财务复审通过,首版报价已生成。E 签宝尚未配置,报价确认单暂不能推送;线下签署后可直接开通。');
}
try {
$this->service->pushQuote($onboarding->fresh(), (int) user_id());
} catch (ValidationException $exception) {
$message = collect($exception->errors())->flatten()->first() ?: '推送失败';
CourierOnboardingEvent::create([
'company_id' => $onboarding->company_id, 'onboarding_id' => $onboarding->id, 'event_type' => 'quote_push_failed',
'from_status' => $onboarding->status, 'to_status' => $onboarding->status, 'note' => $message,
'actor_type' => 'user', 'actor_id' => (int) user_id(),
]);
return $this->back($onboarding, '财务复审通过,首版报价已生成,但报价确认单推送失败:' . $message . '。可在右侧重新推送。');
}
return $this->back($onboarding, '财务复审通过,报价确认单已推送到快递的 e签宝,等待对方签署。');
}
public function pushQuote(CourierOnboarding $onboarding): RedirectResponse
{
$this->ensureAdmin();
$this->service->pushQuote($onboarding, (int) user_id());
return $this->back($onboarding, '报价确认单已推送到快递的 e签宝,等待对方签署。');
}
public function syncQuote(CourierOnboarding $onboarding): RedirectResponse
{
$signature = $this->service->syncQuote($onboarding, (int) user_id());
return $this->back($onboarding, '已同步 e签宝状态:' . $signature->statusLabel() . '。');
}
public function signUrl(CourierOnboarding $onboarding): RedirectResponse
{
$url = $this->service->quoteSignUrl($onboarding);
return $this->back($onboarding, '已获取签署链接,签署人用手机打开或扫码即可签署。')->with('quote_sign_url', $url);
}
public function activate(Request $request, CourierOnboarding $onboarding): RedirectResponse
{
$this->ensureAdmin();
$note = $request->validate(['offline_note' => ['nullable', 'string', 'max:1000']])['offline_note'] ?? null;
$this->service->activate($onboarding, (int) user_id(), $note);
return $this->back($onboarding, '快递已开通,快递资料「入驻快递」里可以查到。');
}
private function formUrl(CourierOnboarding $onboarding): string
{
$base = trim((string) config('courier_onboarding.public_url', ''));
if ($base === '') {
return route('courier-onboarding.form', ['token' => $onboarding->token]);
}
return rtrim($base, '/') . route('courier-onboarding.form', ['token' => $onboarding->token], false);
}
private function back(CourierOnboarding $onboarding, string $message): RedirectResponse
{
return redirect()->route('business.courier-onboardings.show', $onboarding)->with('success', $message);
}
private function ensureReady(): void
{
abort_unless($this->service->ready(), 503, '快递入驻数据表尚未完成迁移,请联系管理员执行数据库迁移。');
}
private function isAdmin(): bool
{
return in_array(SystemAccess::roleName(user()), [SystemAccess::SUPER_ADMIN, SystemAccess::ADMIN], true);
}
private function ensureAdmin(): void
{
abort_unless($this->isAdmin(), 403, '财务复审和开通需要管理员账号。');
}
}

View File

@ -0,0 +1,125 @@
<?php
namespace App\Http\Controllers\Business;
use App\Abstracts\Http\Controller;
use App\Business\CourierProfileData;
use App\Models\Business\CourierPaymentAccount;
use App\Models\Business\CourierProfile;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
/**
* 快递资料在财务系统的维护:编辑主信息(存覆盖值,不回写工作平台),打款信息新增/变更(保存后跳协议签署推送)。
*/
class CourierProfiles extends Controller
{
public function __construct(private readonly CourierProfileData $profiles)
{
}
public function update(Request $request, string $expcode): RedirectResponse
{
$row = $this->profiles->currentRow($expcode);
abort_if($row === null, 404);
$rules = [];
foreach (CourierProfile::EDITABLE as $field => $label) {
$rules[$field] = match (true) {
in_array($field, CourierProfile::BOOLEAN_FIELDS, true) => ['nullable', 'in:0,1'],
in_array($field, CourierProfile::DATE_FIELDS, true) => ['nullable', 'date'],
$field === 'expname' => ['required', 'string', 'max:100'],
default => ['nullable', 'string', 'max:255'],
};
}
$data = $request->validate($rules, ['expname.required' => '快递公司名称不能为空。'], CourierProfile::EDITABLE);
$count = $this->profiles->update($expcode, $row->expname ?? null, $row, $data, (int) user_id());
return redirect($this->pageUrl($expcode, 'logs'))
->with('success', $count > 0 ? "已保存 {$count} 项修改(财务系统维护,不回写工作平台),修改记录见下方操作日志。" : '没有修改任何字段。');
}
/** 打款信息页签里发起:快递公司代码在表单里填。 */
public function storePaymentAccountForCode(Request $request): RedirectResponse
{
$data = $request->validate(
['expcode' => ['required', 'string', 'max:64', 'regex:/^[0-9A-Za-z_\-]+$/']],
['expcode.required' => '请填写快递公司代码。', 'expcode.regex' => '快递公司代码只能是字母、数字、下划线或短横线。'],
);
return $this->storePaymentAccount($request, trim($data['expcode']));
}
public function storePaymentAccount(Request $request, string $expcode): RedirectResponse
{
$row = $this->profiles->currentRow($expcode);
if ($row === null) {
throw \Illuminate\Validation\ValidationException::withMessages(['expcode' => "快递资料里没有快递公司代码「{$expcode}」,请核对。"]);
}
$change = $request->input('action') === CourierPaymentAccount::ACTION_CHANGE;
$number = ['required', 'string', 'max:64', 'regex:/^[0-9A-Za-z\- ]+$/'];
$data = $request->validate([
'action' => ['required', 'in:add,change'],
'account_type' => ['required', 'in:' . implode(',', CourierPaymentAccount::ACCOUNT_TYPES)],
'account_name' => ['required', 'string', 'max:255'],
'bank_name' => ['required', 'string', 'max:255'],
'account_number' => $number,
'effective_date' => ['required', 'date'],
'old_account_name' => [$change ? 'required' : 'nullable', 'string', 'max:255'],
'old_account_number' => $change ? $number : ['nullable', 'string', 'max:64'],
'old_stop_date' => [$change ? 'required' : 'nullable', 'date'],
'change_date' => [$change ? 'required' : 'nullable', 'date'],
'note' => ['nullable', 'string', 'max:500'],
], [
'account_number.regex' => '银行账号只能包含数字、字母和短横线。',
'old_account_number.regex' => '原账号只能包含数字、字母和短横线。',
], [
'account_type' => '账户类型', 'account_name' => $change ? '新开户名称' : '开户名称', 'bank_name' => $change ? '新开户银行' : '开户银行',
'account_number' => $change ? '新银行账号' : '银行账号', 'effective_date' => $change ? '新账户启用日期' : '生效日期',
'old_account_name' => '原开户名称', 'old_account_number' => '原账号', 'old_stop_date' => '原账户停止使用日期', 'change_date' => '账户变更日期',
]);
$strip = static fn (?string $value): ?string => $value === null ? null : (string) preg_replace('/\s+/u', '', $value);
$record = CourierPaymentAccount::query()->create([
'company_id' => company_id(),
'expcode' => $expcode,
'courier_name' => $row->expname ?? null,
'action' => $data['action'],
'account_type' => $data['account_type'],
'account_name' => trim($data['account_name']),
'bank_name' => trim($data['bank_name']),
'account_number' => $strip($data['account_number']),
'effective_date' => $data['effective_date'],
'old_account_name' => $change ? trim((string) $data['old_account_name']) : null,
'old_account_number' => $change ? $strip($data['old_account_number']) : null,
'old_stop_date' => $change ? $data['old_stop_date'] : null,
'change_date' => $change ? $data['change_date'] : null,
'note' => $data['note'] ?? null,
'created_by' => (int) user_id(),
'updated_by' => (int) user_id(),
]);
$summary = $change
? "打款信息变更:{$record->old_account_name} → {$record->account_name}({$record->account_type})"
: "打款信息新增:{$record->account_name}({$record->account_type},{$record->bank_name})";
$this->profiles->log($expcode, $change ? 'account_change' : 'account_add', $summary . ',待推送' . ($change ? '收款账户变更' : '收款账户证明') . '协议', null, (int) user_id());
return redirect()->route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'agreement-signing',
'agreement_view' => 'create',
'payment_account' => $record->id,
])->with('success', $summary . '已保存。请核对下方预填的协议内容,补齐签署人信息后推送给快递签署;签署完成后这条打款信息显示为「已生效」。');
}
private function pageUrl(string $expcode, string $detailTab): string
{
return route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'courier-information',
'selected' => $expcode,
'detail_tab' => $detailTab,
]) . '#courier-detail';
}
}

View File

@ -5,7 +5,9 @@ namespace App\Http\Controllers\Business;
use App\Models\Business\CourierQuoteChange;
use App\Services\Esign\EsignClient;
use App\Support\CourierQuoteChangeService;
use App\Support\CourierOnboardingService;
use App\Support\CourierQuoteSignatureService;
use App\Support\CourierRequoteService;
use App\Support\QuoteManagementSnapshot;
use App\Utilities\SystemAccess;
use Illuminate\Http\RedirectResponse;
@ -19,6 +21,7 @@ class CourierQuoteChanges extends Controller
private readonly CourierQuoteChangeService $service,
private readonly QuoteManagementSnapshot $snapshot,
private readonly CourierQuoteSignatureService $signatureService,
private readonly CourierRequoteService $requotes,
) {
}
@ -35,7 +38,7 @@ class CourierQuoteChanges extends Controller
]);
}
public function create(Request $request): View
public function create(Request $request): View|RedirectResponse
{
$quotes = $this->snapshot->load()['quotes'] ?? [];
abort_if($quotes === [], 422, '尚未导入可用于变更的报价版本。');
@ -43,6 +46,11 @@ class CourierQuoteChanges extends Controller
$selectedId = (string) $request->query('source_quote_id', $quotes[0]['id']);
$source = $this->service->findSourceQuote($selectedId);
// 已入驻快递的报价走后台变价页(可改价格,也可改计费方式),推送按入驻资料签署。
if ($request->has('source_quote_id') && ($onboarding = $this->requotes->onboardingForCourier($source['company_name'] ?? null))) {
return redirect()->route('business.courier-onboardings.requote', $onboarding);
}
return view('business.pages.courier-payables.quote-change-editor', $this->editorData(null, $quotes, $source));
}
@ -56,20 +64,34 @@ class CourierQuoteChanges extends Controller
public function show(CourierQuoteChange $quoteChange): View
{
$this->refreshSignatures();
$quoteChange->load(['items', 'events.actor', 'version.signature']);
$onboarding = $quoteChange->hasQuotePayload() ? $this->requotes->onboardingForCourier($quoteChange->carrier_name) : null;
$requoteDiff = $onboarding !== null
? $this->requotes->diff($onboarding, $this->requotes->basePayload($onboarding, $quoteChange), $quoteChange->quote_payload)
: null;
return view('business.pages.courier-payables.quote-change-show', [
'change' => $quoteChange,
'canReview' => $this->canReview() && $quoteChange->created_by !== user_id(),
'onboarding' => $onboarding,
'requoteDiff' => $requoteDiff,
'reviewWarnings' => $quoteChange->hasQuotePayload() && $quoteChange->status !== CourierQuoteChange::STATUS_APPROVED
? $this->requotes->reviewWarnings($quoteChange, $requoteDiff)
: [],
'voidedBy' => $quoteChange->version?->voided_by ? user_model_class()::query()->find($quoteChange->version->voided_by)?->name : null,
'canReview' => $this->canReview() && ($quoteChange->created_by !== user_id() || config('courier_onboarding.allow_same_reviewer')),
'canManageSignature' => $this->canReview(),
'esignConfigured' => EsignClient::configured(),
'esignEnvironment' => (string) config('services.esign.environment', 'sandbox'),
]);
}
public function edit(CourierQuoteChange $quoteChange): View
public function edit(CourierQuoteChange $quoteChange): View|RedirectResponse
{
abort_unless($quoteChange->isEditable() && $quoteChange->created_by === user_id(), 403);
if ($quoteChange->hasQuotePayload() && ($onboarding = $this->requotes->onboardingForCourier($quoteChange->carrier_name))) {
return redirect()->route('business.courier-onboardings.requote', $onboarding);
}
$quotes = $this->snapshot->load()['quotes'] ?? [];
$source = $this->service->findSourceQuote($quoteChange->source_quote_id);
$quoteChange->load('items');
@ -112,10 +134,48 @@ class CourierQuoteChanges extends Controller
$message .= ' 历史生效已登记,但补差核算尚未执行,请查看核算状态和阻断原因。';
}
// 已入驻快递:按入驻资料直接推送到快递的 e签宝。
if ($change->hasQuotePayload() && ($onboarding = $this->requotes->onboardingForCourier($change->carrier_name))) {
try {
app(CourierOnboardingService::class)->pushVersion($onboarding, $change->version, (int) user_id());
$message .= " 已推送到快递 e签宝({$onboarding->esign_operator_name} {$onboarding->esign_account}),等待签署。";
} catch (\Throwable $exception) {
report($exception);
$reason = $exception instanceof \Illuminate\Validation\ValidationException ? collect($exception->errors())->flatten()->first() : '推送失败';
$message .= " 推送 e签宝失败:{$reason},可在下方重新推送。";
}
}
return redirect()->route('business.courier-quote-changes.show', $change)
->with('success', $message);
}
/** 已入驻快递的变价版本按入驻资料(重新)推送到快递 e签宝。 */
public function pushToCourier(CourierQuoteChange $quoteChange): RedirectResponse
{
abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。');
$onboarding = $this->requotes->onboardingForCourier($quoteChange->carrier_name);
abort_if($onboarding === null || $quoteChange->version === null, 404);
app(CourierOnboardingService::class)->pushVersion($onboarding, $quoteChange->version, (int) user_id());
return redirect()->route('business.courier-quote-changes.show', $quoteChange)
->with('success', "{$quoteChange->version->version_code} 已推送到快递 e签宝,等待签署。");
}
/** 作废发错的报价版本(未签完的同时撤回 e签宝 签署任务)。 */
public function void(Request $request, CourierQuoteChange $quoteChange): RedirectResponse
{
abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。');
abort_if($quoteChange->version === null, 404);
$data = $request->validate(
['void_reason' => ['required', 'string', 'min:2', 'max:500']],
['void_reason.required' => '请写明作废原因,例如:江苏面单多打了一位。'],
);
$message = $this->requotes->voidVersion($quoteChange->version, trim($data['void_reason']), (int) user_id());
return redirect()->route('business.courier-quote-changes.show', $quoteChange)->with('success', $message);
}
public function returnForChanges(Request $request, CourierQuoteChange $quoteChange): RedirectResponse
{
abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。');
@ -242,6 +302,15 @@ class CourierQuoteChanges extends Controller
return $name;
}
private function refreshSignatures(): void
{
try {
$this->signatureService->refreshPending();
} catch (\Throwable $exception) {
report($exception);
}
}
private function canReview(): bool
{
return in_array(SystemAccess::roleName(user()), [SystemAccess::SUPER_ADMIN, SystemAccess::ADMIN], true);

View File

@ -11,6 +11,7 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
class CourierQuoteSignatures extends Controller
@ -86,6 +87,146 @@ class CourierQuoteSignatures extends Controller
return \Storage::disk('uploads')->download($path, $fileName, ['Content-Type' => 'application/pdf']);
}
/**
* 批量导出已签署的回签单电子档(签署 PDF + 签署完成证书)为 zip。
* codes 为报价版本号,逗号分隔;不传则导出全部已归档的回签单。
*/
public function export(Request $request): BinaryFileResponse
{
$codes = array_values(array_filter(array_map('trim', explode(',', (string) $request->query('codes', '')))));
$signatures = CourierQuoteSignature::query()
->with('version')
->where('status', CourierQuoteSignature::STATUS_ARCHIVED)
->whereNotNull('signed_pdf_path')
->when($codes !== [], fn ($query) => $query->whereHas('version', fn ($version) => $version->whereIn('version_code', $codes)))
->orderBy('id')
->get();
abort_if($signatures->isEmpty(), 404, '没有可导出的已签署回签单。');
$disk = \Storage::disk('uploads');
$zipPath = tempnam(sys_get_temp_dir(), 'quote-signed-');
$zip = new \ZipArchive();
abort_unless($zip->open($zipPath, \ZipArchive::OVERWRITE) === true, 500, '生成压缩包失败。');
$added = 0;
foreach ($signatures as $signature) {
$version = $signature->version;
$base = $this->safeName(($version->company_name ?: $version->carrier_name) . '-' . $version->version_code);
if ($disk->exists($signature->signed_pdf_path)) {
$zip->addFromString("{$base}-回签单.pdf", $disk->get($signature->signed_pdf_path));
$added++;
}
if ($signature->certificate_path && $disk->exists($signature->certificate_path)) {
$zip->addFromString("{$base}-签署完成证书.pdf", $disk->get($signature->certificate_path));
}
}
$zip->close();
abort_if($added === 0, 404, '回签单归档文件不存在。');
$name = $signatures->count() === 1
? $this->safeName($signatures->first()->version->version_code) . '-回签单电子档.zip'
: '快递报价回签单-' . now()->format('Ymd-His') . '.zip';
return response()->download($zipPath, $name, ['Content-Type' => 'application/zip'])->deleteFileAfterSend();
}
/**
* 导出报价电子表格(xlsx,WPS / Excel 都能打开):只保留「省份」表头和各省价格,
* 不带标题、执行时间、计费说明和备注等文字行。
*/
public function sheet(CourierQuoteVersion $quoteVersion): StreamedResponse
{
$values = json_decode((string) $quoteVersion->values_json, true);
$table = self::quoteTable(is_array($values) ? $values : []);
abort_if($table === [], 404, '这份报价没有可导出的价格表。');
$spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle(mb_substr($this->safeName($quoteVersion->version_code), 0, 31));
foreach ($table as $rowIndex => $row) {
foreach ($row as $columnIndex => $value) {
$cell = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($columnIndex + 1) . ($rowIndex + 1);
if (is_int($value) || is_float($value)) {
$sheet->setCellValue($cell, $value);
} elseif ($value !== null && $value !== '') {
$sheet->setCellValueExplicit($cell, (string) $value, \PhpOffice\PhpSpreadsheet\Cell\DataType::TYPE_STRING);
}
}
}
$lastColumn = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex(count($table[0]));
$range = 'A1:' . $lastColumn . count($table);
$sheet->getStyle($range)->getBorders()->getAllBorders()->setBorderStyle(\PhpOffice\PhpSpreadsheet\Style\Border::BORDER_THIN);
$sheet->getStyle($range)->getAlignment()->setHorizontal(\PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_CENTER)
->setVertical(\PhpOffice\PhpSpreadsheet\Style\Alignment::VERTICAL_CENTER)->setWrapText(true);
$header = $sheet->getStyle('A1:' . $lastColumn . '1');
$header->getFont()->setBold(true);
$header->getFill()->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)->getStartColor()->setRGB('F3EAD7');
$sheet->getColumnDimension('A')->setWidth(12);
for ($i = 2; $i <= count($table[0]); $i++) {
$sheet->getColumnDimension(\PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($i))->setWidth(16);
}
$sheet->freezePane('B2');
$fileName = $this->safeName(($quoteVersion->company_name ?: $quoteVersion->carrier_name) . '-' . $quoteVersion->version_code) . '-报价.xlsx';
return response()->streamDownload(function () use ($spreadsheet): void {
(new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet))->save('php://output');
}, $fileName, ['Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']);
}
/**
* 从报价矩阵里截出价格表:从「省份」表头行开始,到第一行只有一格文字(说明、备注)为止。
*
* @param array<int,mixed> $values
* @return list<list<string|int|float|null>>
*/
public static function quoteTable(array $values): array
{
$rows = array_values(array_filter($values, 'is_array'));
$filled = static fn (array $row): int => count(array_filter($row, static fn ($cell): bool => $cell !== null && trim((string) $cell) !== ''));
$start = null;
foreach ($rows as $index => $row) {
$first = trim((string) ($row[0] ?? ''));
if (preg_match('/^(省份|省市|区域|地区|目的地|目的省份)/u', $first) === 1 && $filled($row) >= 2) {
$start = $index;
break;
}
}
if ($start === null) {
foreach ($rows as $index => $row) {
if ($filled($row) >= 2) {
$start = $index;
break;
}
}
}
if ($start === null) {
return [];
}
$table = [];
for ($i = $start; $i < count($rows) && $filled($rows[$i]) >= 2; $i++) {
$table[] = array_values($rows[$i]);
}
$width = 0;
foreach ($table as $row) {
foreach ($row as $column => $cell) {
if ($cell !== null && trim((string) $cell) !== '') {
$width = max($width, $column + 1);
}
}
}
return array_map(static fn (array $row): array => array_pad(array_slice($row, 0, $width), $width, null), $table);
}
private function safeName(string $name): string
{
return trim(preg_replace('~[\\\\/:*?"<>|\s]+~u', '_', $name), '_') ?: 'quote';
}
private function validateAccountForNotice(string $account, string $noticeType): void
{
$valid = $noticeType === '1'

View File

@ -2749,12 +2749,30 @@ class Pages extends Controller
}
try {
return collect($this->courierInformation->names());
$names = collect((array) $this->courierInformation->names());
} catch (Throwable $exception) {
// 快递镜像不可用时仍允许打开签署页,并明确展示暂无可选资料。
report($exception);
$names = collect();
}
// 财务系统入驻的快递也能直接选(不必等进工作平台)。
return $names->merge($this->onboardedCourierSigning()->keys())->filter()->unique()->values();
}
/** @return Collection<string,string> 已入驻快递:系统快递名称 => E 签宝账号 */
private function onboardedCourierSigning(): Collection
{
if (! \Illuminate\Support\Facades\Schema::hasTable('business_courier_onboardings')) {
return collect();
}
return \App\Models\Business\CourierOnboarding::query()
->whereIn('status', [\App\Models\Business\CourierOnboarding::STATUS_APPROVED, \App\Models\Business\CourierOnboarding::STATUS_ACTIVATED])
->whereNotNull('courier_name')
->latest('id')
->get(['courier_name', 'esign_account'])
->mapWithKeys(static fn ($onboarding): array => [(string) $onboarding->courier_name => (string) $onboarding->esign_account]);
}
protected function agreementSupplierOptions(?string $page): array
@ -2776,14 +2794,21 @@ class Pages extends Controller
}
try {
return $this->courierInformation->signingAccountsByName(
$accounts = (array) $this->courierInformation->signingAccountsByName(
($courierNames ?? $this->agreementCourierNames($page))->values()->all(),
);
} catch (Throwable $exception) {
// 快递镜像不可用时仍可手工填写 E 签宝账号,不能阻断签署页。
report($exception);
return [];
$accounts = [];
}
foreach ($this->onboardedCourierSigning() as $name => $account) {
if ($account !== '' && blank($accounts[$name] ?? null)) {
$accounts[$name] = $account;
}
}
return $accounts;
}
protected function agreementTasks(?string $page): Collection|\Illuminate\Contracts\Pagination\LengthAwarePaginator
@ -2989,6 +3014,7 @@ class Pages extends Controller
'normal-products' => $this->contentForNormalProducts(),
'product-prices' => $this->contentForProductPrices(),
'courier-information' => $this->contentForCourierInformation(),
'courier-onboarding' => app(\App\Business\CourierOnboardingPageData::class)->page(),
'freight-reconciliation' => $this->contentForFreightReconciliation(),
'third-party-warehouse-storage' => $this->contentForThirdPartyWarehouseStorage(),
'warehouse-freight' => ['view' => 'business.pages.courier-payables.warehouse-freight'],
@ -6463,11 +6489,12 @@ class Pages extends Controller
public function exportCourierInformation(Request $request): StreamedResponse
{
abort_unless($this->courierInformation->available() && $this->courierInformation->ready(), 503);
$platformReady = $this->courierInformation->available() && $this->courierInformation->ready();
$filters = $this->courierInformationFilters($request);
$profiles = app(\App\Business\CourierProfileData::class);
$overview = app(\App\Business\CourierOverviewData::class);
return response()->streamDownload(function () use ($filters): void {
return response()->streamDownload(function () use ($filters, $platformReady, $profiles, $overview): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建快递资料导出流。');
@ -6479,39 +6506,58 @@ class Pages extends Controller
'是否启用', '可达服务', '是否快运', '自动寻号', '配货单号寻号', '数量拆单', '重量0寻号',
'停运时间', '合同开始时间', '合同结束时间', '联系人', '联系电话', '自定义1', '自定义2', '自定义3',
'体积0寻号', '手工单号', '解密', '电子签章经办人', '最后修改',
'入驻单号', '入驻状态', '入驻主体', '财务系统已修改字段',
]);
$bool = static fn (mixed $value): string => in_array(strtolower((string) $value), ['1', 'true', 'yes', 'y'], true) || $value === true ? '是' : '否';
$date = static fn (mixed $value): string => $value === null || $value === '' ? '' : substr((string) $value, 0, 10);
foreach ($this->courierInformation->exportRows($filters) as $row) {
// 财务系统入驻(还没有工作平台记录)的快递排最前面;所有行都用财务系统编辑后的值。
$extra = array_map([$profiles, 'onboardingRow'], $overview->expressAppend([], 1, $filters['expname'] ?? '', $filters['expcode'] ?? '')['extra']);
$platformRows = $platformReady ? $this->courierInformation->exportRows($filters) : [];
$overrides = null;
$rowsToWrite = (function () use ($extra, $platformRows) {
yield from $extra;
yield from $platformRows;
})();
foreach ($rowsToWrite as $row) {
$overrides ??= \Illuminate\Support\Facades\Schema::hasTable('business_courier_profiles')
? \App\Models\Business\CourierProfile::query()->get()->mapWithKeys(static fn ($profile): array => [(string) $profile->expcode => (array) $profile->overrides])->all()
: [];
$row = $profiles->apply($row, $overrides[(string) ($row->expcode ?? '')] ?? []);
$info = $row->_info ?? $overview->infoFor((string) ($row->expcode ?? ''), $row->expname ?? null);
$onboarding = $info['onboarding'] ?? null;
fputcsv($csv, [
$row->Id,
$this->csvText($row->expcode),
$this->csvText($row->expname),
$this->csvText($row->outcode),
$this->csvText($row->scexpcode),
$this->csvText($row->paymethod),
$bool($row->status),
$bool($row->addvalidsgn),
$bool($row->kuaiyunsgn),
$bool($row->autonosgn),
$bool($row->packnosgn),
$bool($row->skucountsplitsgn),
$bool($row->weight0sgn),
$date($row->stopdate),
$date($row->tradebtime),
$date($row->tradeetime),
$this->csvText($row->linker),
$this->csvText($row->linkmobile),
$this->csvText($row->undefined1),
$this->csvText($row->undefined2),
$this->csvText($row->undefined3),
$bool($row->volume0sgn),
$bool($row->handnosgn),
$bool($row->decryptsgn),
$this->csvText($row->dzqzjbr),
$date($row->modified),
$row->Id ?? '',
$this->csvText($row->expcode ?? null),
$this->csvText($row->expname ?? null),
$this->csvText($row->outcode ?? null),
$this->csvText($row->scexpcode ?? null),
$this->csvText($row->paymethod ?? null),
$bool($row->status ?? null),
$bool($row->addvalidsgn ?? null),
$bool($row->kuaiyunsgn ?? null),
$bool($row->autonosgn ?? null),
$bool($row->packnosgn ?? null),
$bool($row->skucountsplitsgn ?? null),
$bool($row->weight0sgn ?? null),
$date($row->stopdate ?? null),
$date($row->tradebtime ?? null),
$date($row->tradeetime ?? null),
$this->csvText($row->linker ?? null),
$this->csvText($row->linkmobile ?? null),
$this->csvText($row->undefined1 ?? null),
$this->csvText($row->undefined2 ?? null),
$this->csvText($row->undefined3 ?? null),
$bool($row->volume0sgn ?? null),
$bool($row->handnosgn ?? null),
$bool($row->decryptsgn ?? null),
$this->csvText($row->dzqzjbr ?? null),
$date($row->modified ?? null),
$this->csvText($onboarding?->number),
$this->csvText($onboarding?->statusLabel()),
$this->csvText($onboarding ? $onboarding->entityLabel() . ' · ' . $onboarding->company_full_name : null),
$this->csvText(implode('、', array_map(static fn (string $field): string => \App\Models\Business\CourierProfile::EDITABLE[$field], $row->_overridden ?? []))),
]);
}
@ -6682,6 +6728,7 @@ class Pages extends Controller
protected function contentForCourierInformation(): array
{
$filters = $this->courierInformationFilters(request());
// 快递资料四类:快递资料(主信息)/ 网点 / 打款信息 / 操作日志;点开主信息在下方看这家快递的明细。
$tab = (string) request()->query('courier_tab', 'express');
$tab = in_array($tab, ['express', 'networks', 'accounts', 'logs'], true) ? $tab : 'express';
$page = max((int) request()->query('page', 1), 1);
@ -6695,8 +6742,15 @@ class Pages extends Controller
$state = null;
$syncStatus = ['code' => 'unavailable', 'lag_minutes' => null];
$relatedAvailability = $this->courierInformation->relatedAvailability();
$profiles = app(\App\Business\CourierProfileData::class);
$overview = app(\App\Business\CourierOverviewData::class);
try {
// 网点 / 打款信息 / 操作日志页签也显示快递资料主表的同步状态,避免误显示「镜像未建立」。
if ($tab !== 'express' && $this->courierInformation->available()) {
$state = $this->courierInformation->state();
$syncStatus = $this->courierInformation->syncStatus($state);
}
if ($tab === 'express') {
if (! $this->courierInformation->available()) {
$error = '共享业务镜像尚未建立,快递资料暂时无法读取。';
@ -6723,20 +6777,20 @@ class Pages extends Controller
}
} elseif ($tab === 'accounts') {
if (! ($relatedAvailability['accounts'] ?? false)) {
$error = '打款账户镜像尚未建立,请先完成 wp_express_account 的首次同步。';
$error = '工作平台打款账户镜像尚未建立,下面只显示财务系统里的打款信息。';
} elseif (! ($relatedAvailability['account_ready'] ?? false)) {
$pending = true;
$error = '打款账户正在执行首次全量同步,完成前不会把未同步数据误判为无数据。';
$error = '工作平台打款账户正在执行首次全量同步,下面先显示财务系统里的打款信息。';
} else {
$result = $this->courierInformation->paginateAccounts($filters['expcode'] ?: null, $page, $limit);
$rows = $this->courierPaginator($result['data'] ?? [], $result['total'] ?? 0, $limit, $page);
}
} else {
if (! ($relatedAvailability['logs'] ?? false)) {
$error = '操作日志镜像尚未建立,请先完成 wp_express_extension 的首次同步。';
$error = '工作平台操作日志镜像尚未建立,下面只显示财务系统里的操作日志。';
} elseif (! ($relatedAvailability['log_ready'] ?? false)) {
$pending = true;
$error = '操作日志正在执行首次全量同步,完成前不会把未同步数据误判为无数据。';
$error = '工作平台操作日志正在执行首次全量同步,下面先显示财务系统里的操作日志。';
} else {
$result = $this->courierInformation->paginateLogs($filters['expcode'] ?: null, $page, $limit);
$rows = $this->courierPaginator($result['data'] ?? [], $result['total'] ?? 0, $limit, $page);
@ -6747,11 +6801,47 @@ class Pages extends Controller
$error = '快递资料共享镜像暂时无法读取,请稍后重试。';
}
// 主信息:财务系统入驻的快递排在第一页最前面,工作平台的行右侧接上入驻信息;编辑过的字段用财务系统的值。
$displayRows = [];
$financeAccounts = [];
$financeEvents = collect();
try {
if ($tab === 'express') {
$append = $overview->expressAppend(
$rows->items(),
$page,
$filters['expname'] ?? '',
$filters['expcode'] ?? '',
);
$platformRows = array_map(static function (object $row) use ($append): object {
$row = clone $row;
$row->_info = $append['matches'][(string) ($row->Id ?? $row->expcode)] ?? null;
return $row;
}, $rows->items());
$displayRows = array_merge(array_map([$profiles, 'onboardingRow'], $append['extra']), $platformRows);
$overrides = $profiles->overrides(array_map(static fn (object $row): string => (string) ($row->expcode ?? ''), $displayRows));
$displayRows = array_map(static fn (object $row): object => $profiles->apply($row, $overrides[(string) ($row->expcode ?? '')] ?? []), $displayRows);
} elseif ($tab === 'accounts' && $page === 1) {
$financeAccounts = $profiles->financeAccounts($filters['expcode'] ?: null);
} elseif ($tab === 'logs' && $page === 1) {
$financeEvents = $profiles->events($filters['expcode'] ?: null);
}
} catch (Throwable $exception) {
report($exception);
}
$detail = $tab === 'express' ? $this->courierInformationDetail($displayRows, $relatedAvailability) : null;
return [
'view' => 'business.pages.courier-payables.courier-information',
'courierTab' => $tab,
'courierFilters' => $filters,
'courierRows' => $rows,
'courierDisplayRows' => $displayRows,
'courierDetail' => $detail,
'courierFinanceAccounts' => $financeAccounts,
'courierFinanceEvents' => $financeEvents,
'courierState' => $state,
'courierSyncStatus' => $syncStatus,
'courierError' => $error,
@ -6760,6 +6850,53 @@ class Pages extends Controller
];
}
/** 选中一行主信息(?selected=快递公司代码)后,下方显示这家快递的网点 / 打款信息 / 操作日志。 */
private function courierInformationDetail(array $displayRows, array $availability): ?array
{
$code = mb_substr(trim((string) request()->query('selected', '')), 0, 64);
if ($code === '') {
return null;
}
$profiles = app(\App\Business\CourierProfileData::class);
$overview = app(\App\Business\CourierOverviewData::class);
$row = collect($displayRows)->first(static fn (object $item): bool => strcasecmp((string) ($item->expcode ?? ''), $code) === 0);
$row ??= $profiles->currentRow($code);
if ($row === null) {
return ['code' => $code, 'row' => null];
}
$info = $row->_info ?? $overview->infoFor($code, $row->expname ?? null);
$onboarding = $info['onboarding'] ?? null;
$networks = $platformAccounts = $platformLogs = [];
try {
if (($availability['network_ready'] ?? false)) {
$networks = $this->courierInformation->paginateNetworks($code, 1, 100)['data'] ?? [];
}
if (($availability['account_ready'] ?? false)) {
$platformAccounts = $this->courierInformation->paginateAccounts($code, 1, 100)['data'] ?? [];
}
if (($availability['log_ready'] ?? false)) {
$platformLogs = $this->courierInformation->paginateLogs($code, 1, 100)['data'] ?? [];
}
} catch (Throwable $exception) {
report($exception);
}
return [
'code' => $code,
'row' => $row,
'onboarding' => $onboarding,
'networks' => $networks,
'platform_accounts' => $platformAccounts,
'finance_accounts' => $profiles->financeAccounts($code, $onboarding),
'platform_logs' => $platformLogs,
'events' => $profiles->events($code),
'edit' => request()->boolean('edit'),
'detail_tab' => in_array(request()->query('detail_tab'), ['networks', 'accounts', 'logs'], true) ? request()->query('detail_tab') : 'accounts',
];
}
/** @return array<string,string> */
private function courierInformationFilters(Request $request): array
{

View File

@ -0,0 +1,180 @@
<?php
namespace App\Http\Controllers\Onboarding;
use App\Models\Business\CourierOnboarding;
use App\Models\Business\CourierOnboardingFile;
use App\Models\Common\Company;
use App\Support\CourierOnboardingService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
/**
* 快递方扫码打开的免登录入驻填报页。链接里的随机 token 就是凭证,只能读写这一份申请。
*/
class CourierOnboardingForms extends Controller
{
public function __construct(private readonly CourierOnboardingService $service)
{
}
public function show(string $token): View|Response
{
$onboarding = $this->resolve($token);
if ($onboarding === null) {
return $this->unavailable();
}
$onboarding->load('files');
return view('onboarding.courier-form', [
'onboarding' => $onboarding,
'token' => $token,
'provinces' => CourierOnboarding::PROVINCES,
'fileKinds' => CourierOnboardingFile::KINDS,
'requiredKinds' => CourierOnboardingFile::REQUIRED_KINDS,
'personKinds' => CourierOnboardingFile::PERSON_KINDS,
'personRequiredKinds' => CourierOnboardingFile::PERSON_REQUIRED_KINDS,
]);
}
public function update(Request $request, string $token): RedirectResponse|Response
{
$onboarding = $this->resolve($token);
if ($onboarding === null) {
return $this->unavailable();
}
$data = $request->validate($this->rules(), $this->messages());
$files = [];
foreach ((array) $request->file('files', []) as $kind => $uploads) {
$files[(string) $kind] = array_values(array_filter(is_array($uploads) ? $uploads : [$uploads]));
}
$submit = ($data['action'] ?? 'save') === 'submit';
$this->service->saveCourierForm($onboarding, $data, $files, $submit);
return redirect()
->route('courier-onboarding.form', ['token' => $token])
->with('onboarding_success', $submit ? '资料已提交,我们会尽快审核,审核结果请留意对接人通知。' : '资料已暂存,可随时扫码回来继续填写。');
}
private function resolve(string $token): ?CourierOnboarding
{
$onboarding = $this->service->ready() ? $this->service->findByToken($token) : null;
if ($onboarding === null || $onboarding->status === CourierOnboarding::STATUS_CANCELLED) {
return null;
}
// 免登录访问没有当前公司上下文;切到申请所属公司,后续按公司隔离的查询才能命中。
Company::find($onboarding->company_id)?->makeCurrent();
return $onboarding;
}
/**
* 免登录页不走系统的 404 跳转(会跳到登录页),直接给快递方一个能看懂的提示。
*/
private function unavailable(): Response
{
return response()->view('onboarding.courier-unavailable', [], 404);
}
/**
* @return array<string,mixed>
*/
private function rules(): array
{
$text = ['nullable', 'string', 'max:255'];
$phone = ['nullable', 'string', 'regex:/^[0-9+\-\s()]{5,20}$/'];
$money = ['nullable', 'numeric', 'min:0', 'max:100000'];
return [
'remark' => ['nullable', 'string', 'max:2000'],
'action' => ['required', Rule::in(['save', 'submit'])],
'entity_type' => ['nullable', Rule::in(array_keys(CourierOnboarding::ENTITY_TYPES))],
'id_number' => ['nullable', 'string', 'regex:/^\s*\d{17}[\dXx]\s*$/'],
'company_full_name' => $text,
'unified_social_credit_code' => ['nullable', 'string', 'regex:/^[0-9A-Za-z]{18}$/'],
'legal_representative' => ['nullable', 'string', 'max:100'],
'business_address' => $text,
'carrier_brand' => ['nullable', 'string', 'max:50'],
'network_name' => ['nullable', 'string', 'max:100'],
'service_type' => ['nullable', Rule::in(CourierOnboarding::SERVICE_TYPES)],
'contact_name' => ['nullable', 'string', 'max:100'],
'contact_phone' => $phone,
'finance_contact_name' => ['nullable', 'string', 'max:100'],
'finance_contact_phone' => $phone,
'pickup_warehouses' => ['nullable', 'string', 'max:1000'],
'esign_operator_name' => ['nullable', 'string', 'max:100'],
'esign_account' => ['nullable', 'string', 'max:255'],
'bank_account_name' => $text,
'bank_name' => $text,
'bank_account_number' => ['nullable', 'string', 'regex:/^[0-9\s\-]{6,40}$/'],
'private_account_name' => $text,
'private_bank_name' => $text,
'private_account_number' => ['nullable', 'string', 'regex:/^[0-9\s\-]{6,40}$/'],
'invoice_type' => ['nullable', Rule::in(CourierOnboarding::INVOICE_TYPES)],
'tax_rate' => ['nullable', 'numeric', 'min:0', 'max:20'],
'settlement_cycle' => ['nullable', Rule::in(CourierOnboarding::SETTLEMENT_CYCLES)],
...self::quoteRules(),
'remove_files' => ['nullable', 'array'],
'remove_files.*' => ['integer'],
'files' => ['nullable', 'array'],
'files.*' => ['array', 'max:' . CourierOnboardingService::MAX_FILES_PER_KIND],
'files.*.*' => ['file', 'max:10240', 'mimes:jpg,jpeg,png,webp,gif,heic,heif,pdf,xls,xlsx'],
];
}
/**
* 报价部分的校验规则,后台变价页共用。
*
* @return array<string,mixed>
*/
public static function quoteRules(): array
{
$money = ['nullable', 'numeric', 'min:0', 'max:100000'];
return [
'quote_mode' => ['nullable', Rule::in(array_keys(CourierOnboarding::QUOTE_MODES))],
'volumetric_rule' => ['nullable', Rule::in(CourierOnboarding::VOLUMETRIC_RULES)],
'volumetric_divisor' => ['nullable', 'integer', 'min:1000', 'max:20000'],
'quote_effective_date' => ['nullable', 'date'],
'quote_note' => ['nullable', 'string', 'max:2000'],
'quote_rows' => ['nullable', 'array', 'max:80'],
'quote_rows.*.region' => ['nullable', 'string', 'max:50'],
'quote_rows.*.flat' => ['nullable', 'array', 'max:' . \App\Support\CourierQuoteStructure::MAX_BANDS],
'quote_rows.*.flat.*' => $money,
'quote_rows.*.base' => $money,
'quote_rows.*.rates' => ['nullable', 'array', 'max:' . (\App\Support\CourierQuoteStructure::MAX_TIERS + 1)],
'quote_rows.*.rates.*' => $money,
'quote_flat_bands' => ['nullable', 'string', 'max:200'],
'quote_first_weight' => ['nullable', 'numeric', 'min:0', 'max:1000'],
'quote_rate_tiers' => ['nullable', 'string', 'max:200'],
'quote_then_mode' => ['nullable', Rule::in(['waybill_rate', 'first_additional'])],
'min_weight' => ['nullable', 'numeric', 'min:0', 'max:1000'],
'weight_rounding' => ['nullable', Rule::in(array_keys(\App\Support\CourierQuoteStructure::ROUNDING))],
'per_ticket_surcharge' => ['nullable', 'numeric', 'min:0', 'max:1000'],
];
}
/**
* @return array<string,string>
*/
private function messages(): array
{
return [
'unified_social_credit_code.regex' => '统一社会信用代码应为 18 位数字或字母。',
'contact_phone.regex' => '请填写正确的联系电话。',
'id_number.regex' => '身份证号应为 18 位。',
'finance_contact_phone.regex' => '请填写正确的联系电话。',
'bank_account_number.regex' => '银行账号只能包含数字。',
'private_account_number.regex' => '银行卡号只能包含数字。',
'volumetric_divisor.min' => '抛比系数一般为 5000、6000、8000 等。',
'files.*.*.max' => '单个文件不能超过 10MB。',
'files.*.*.mimes' => '请上传照片、PDF 或 Excel 文件。',
];
}
}

View File

@ -0,0 +1,283 @@
<?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),
));
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Models\Business;
use App\Abstracts\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CourierOnboardingEvent extends Model
{
protected $table = 'business_courier_onboarding_events';
protected $fillable = ['company_id', 'onboarding_id', 'event_type', 'from_status', 'to_status', 'note', 'actor_type', 'actor_id'];
protected $casts = [
'company_id' => 'integer',
'onboarding_id' => 'integer',
'actor_id' => 'integer',
'deleted_at' => 'datetime',
];
public function actor(): BelongsTo
{
return $this->belongsTo(user_model_class(), 'actor_id');
}
public function eventLabel(): string
{
return match ($this->event_type) {
'invited' => '生成入驻二维码',
'saved' => '快递方保存资料',
'submitted' => '快递方提交审核',
'first_approved' => '初审通过',
'returned' => '退回补件',
'final_approved' => '财务复审通过',
'activated' => '确认开通',
'rejected' => '不予合作',
'cancelled' => '作废邀请',
'extended' => '延长链接有效期',
'internal_updated' => '更新内部信息',
'quote_pushed' => '推送报价确认单到 e签宝',
'quote_push_failed' => '报价确认单推送失败',
default => $this->event_type,
};
}
public function actorLabel(): string
{
if ($this->actor_type === 'courier') {
return '快递方';
}
return (string) ($this->actor?->name ?? '系统');
}
}

View File

@ -0,0 +1,80 @@
<?php
namespace App\Models\Business;
use App\Abstracts\Model;
class CourierOnboardingFile extends Model
{
public const KINDS = [
'business_license' => '营业执照',
'legal_id_card_front' => '法人身份证 · 人像面',
'legal_id_card_back' => '法人身份证 · 国徽面',
'bank_card_front' => '对私银行卡 · 正面',
'bank_card_back' => '对私银行卡 · 反面',
];
/** 计费方式选「其他」时上传的报价表,单独显示在报价区块里。 */
public const QUOTE_SHEET_KIND = 'quote_sheet';
/** @return array<string,string> */
public static function allKinds(): array
{
return self::KINDS + [self::QUOTE_SHEET_KIND => '报价表'];
}
/** 个人入驻时的资料名称。 */
public const PERSON_KINDS = [
'business_license' => '营业执照(有就上传,选填)',
'legal_id_card_front' => '身份证 · 人像面',
'legal_id_card_back' => '身份证 · 国徽面',
'bank_card_front' => '对私银行卡 · 正面',
'bank_card_back' => '对私银行卡 · 反面',
];
/** 提交审核前必须上传的资料(每项一张,重新上传会替换)。 */
public const REQUIRED_KINDS = ['business_license', 'legal_id_card_front', 'legal_id_card_back', 'bank_card_front', 'bank_card_back'];
public const PERSON_REQUIRED_KINDS = ['legal_id_card_front', 'legal_id_card_back', 'bank_card_front', 'bank_card_back'];
/** @return list<string> */
public static function requiredKindsFor(?string $entityType): array
{
return $entityType === CourierOnboarding::ENTITY_PERSON ? self::PERSON_REQUIRED_KINDS : self::REQUIRED_KINDS;
}
public static function labelFor(string $kind, ?string $entityType): string
{
return ($entityType === CourierOnboarding::ENTITY_PERSON ? (self::PERSON_KINDS[$kind] ?? null) : null)
?? self::allKinds()[$kind]
?? $kind;
}
protected $table = 'business_courier_onboarding_files';
protected $fillable = ['company_id', 'onboarding_id', 'kind', 'original_name', 'path', 'mime', 'size'];
protected $casts = [
'company_id' => 'integer',
'onboarding_id' => 'integer',
'size' => 'integer',
'deleted_at' => 'datetime',
];
public function kindLabel(): string
{
return self::KINDS[$this->kind] ?? $this->kind;
}
public function isImage(): bool
{
return str_starts_with((string) $this->mime, 'image/');
}
public function sizeLabel(): string
{
$size = (int) $this->size;
return $size >= 1048576 ? sprintf('%.1f MB', $size / 1048576) : sprintf('%d KB', max(1, (int) ceil($size / 1024)));
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Models\Business;
use App\Abstracts\Model;
/**
* 快递资料「打款信息新增 / 变更」。保存后跳到协议签署推送收款账户证明(新增)或收款账户变更(变更),
* 状态按协议签署记录推导:待推送协议 → 签署中 → 已生效。
*/
class CourierPaymentAccount extends Model
{
public const ACTION_ADD = 'add';
public const ACTION_CHANGE = 'change';
public const ACTIONS = [self::ACTION_ADD => '打款信息新增', self::ACTION_CHANGE => '打款信息变更'];
public const ACCOUNT_TYPES = ['对公', '对私'];
/** 对应协议签署里的文件类型。 */
public const DOCUMENT_TYPES = [self::ACTION_ADD => 'account-proof', self::ACTION_CHANGE => 'account-change'];
protected $table = 'business_courier_payment_accounts';
protected $fillable = [
'company_id', 'expcode', 'courier_name', 'action', 'account_type', 'account_name', 'bank_name', 'account_number',
'effective_date', 'old_account_name', 'old_account_number', 'old_stop_date', 'change_date', 'note',
'created_by', 'updated_by',
];
protected $casts = [
'account_number' => 'encrypted',
'old_account_number' => 'encrypted',
'effective_date' => 'date',
'old_stop_date' => 'date',
'change_date' => 'date',
];
public function documentType(): string
{
return self::DOCUMENT_TYPES[$this->action] ?? 'account-proof';
}
public function actionLabel(): string
{
return self::ACTIONS[$this->action] ?? $this->action;
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Models\Business;
use App\Abstracts\Model;
/**
* 快递资料在财务系统里的「编辑」覆盖值(按快递公司代码)。工作平台镜像只读,编辑结果存在这里,
* 页面和导出显示时用覆盖值盖在镜像值上。
*/
class CourierProfile extends Model
{
/** 可在财务系统编辑的快递资料字段(键与工作平台 wp_express 列名一致)。 */
public const EDITABLE = [
'expname' => '快递公司名称',
'paymethod' => '运费打款方式',
'status' => '是否启用',
'kuaiyunsgn' => '是否快运',
'stopdate' => '停运时间',
'tradebtime' => '合同开始时间',
'tradeetime' => '合同结束时间',
'linker' => '联系人',
'linkmobile' => '联系电话',
'undefined1' => '自定义1',
'undefined2' => '自定义2',
'undefined3' => '自定义3',
'dzqzjbr' => '电子签章经办人',
];
public const BOOLEAN_FIELDS = ['status', 'kuaiyunsgn'];
public const DATE_FIELDS = ['stopdate', 'tradebtime', 'tradeetime'];
protected $table = 'business_courier_profiles';
protected $fillable = ['company_id', 'expcode', 'courier_name', 'overrides', 'created_by', 'updated_by'];
protected $casts = ['overrides' => 'array'];
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Models\Business;
use App\Abstracts\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** 快递资料操作日志(财务系统侧):编辑、打款信息新增/变更。 */
class CourierProfileEvent extends Model
{
public const ACTIONS = [
'edit' => '编辑快递资料',
'account_add' => '打款信息新增',
'account_change' => '打款信息变更',
];
protected $table = 'business_courier_profile_events';
protected $fillable = ['company_id', 'expcode', 'action', 'summary', 'changes', 'created_by'];
protected $casts = ['changes' => 'array'];
public function creator(): BelongsTo
{
return $this->belongsTo(\App\Models\Auth\User::class, 'created_by');
}
public function actionLabel(): string
{
return self::ACTIONS[$this->action] ?? $this->action;
}
}

View File

@ -21,17 +21,19 @@ class CourierQuoteChange extends Model
protected $fillable = [
'company_id', 'number', 'source_quote_id', 'source_sheet_name', 'carrier_name', 'quote_type',
'change_type', 'status', 'effective_date', 'is_retrospective', 'recalculation_status',
'change_type', 'status', 'effective_date', 'end_date', 'is_retrospective', 'recalculation_status',
'recalculation_note', 'summary', 'created_by', 'submitted_by', 'submitted_at', 'approved_by',
'approved_at', 'new_quote_version_id',
'approved_at', 'new_quote_version_id', 'quote_payload', 'proposed_values_json',
];
protected $casts = [
'company_id' => 'integer',
'effective_date' => 'date',
'end_date' => 'date',
'is_retrospective' => 'boolean',
'submitted_at' => 'datetime',
'approved_at' => 'datetime',
'quote_payload' => 'array',
];
public function items(): HasMany
@ -49,6 +51,20 @@ class CourierQuoteChange extends Model
return $this->hasOne(CourierQuoteVersion::class, 'source_change_id');
}
/** 按完整报价结构提交的变价(快递入驻后的后台变价 / 重新报价)。 */
/** 执行期间文字,如「2026-10-12 至 2026-10-15(临时)」或「2026-10-01 起」。 */
public function periodLabel(): string
{
return $this->end_date !== null
? $this->effective_date?->toDateString() . ' 至 ' . $this->end_date->toDateString() . '(临时)'
: $this->effective_date?->toDateString() . ' 起';
}
public function hasQuotePayload(): bool
{
return is_array($this->quote_payload) && $this->quote_payload !== [];
}
public function isEditable(): bool
{
return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_RETURNED], true);

View File

@ -33,6 +33,7 @@ class CourierQuoteChangeEvent extends Model
'submitted' => '提交审核',
'returned' => '退回修改',
'approved' => '审核通过',
'voided' => '版本作废',
default => $this->event_type,
};
}

View File

@ -12,15 +12,55 @@ class CourierQuoteVersion extends Model
protected $fillable = [
'company_id', 'version_code', 'source_quote_id', 'source_change_id', 'sheet_name', 'company_name',
'carrier_name', 'quote_type', 'effective_date', 'source_range', 'values_json', 'formulas_json',
'status', 'signature_status', 'created_by', 'approved_by',
'carrier_name', 'quote_type', 'effective_date', 'end_date', 'source_range', 'values_json', 'formulas_json',
'status', 'signature_status', 'created_by', 'approved_by', 'void_reason', 'voided_at', 'voided_by',
];
public const STATUS_APPROVED = 'approved';
/** 发错作废:不再参与生效判断,原件和回签单保留。 */
public const STATUS_VOIDED = 'voided';
protected $casts = [
'company_id' => 'integer',
'effective_date' => 'date',
'end_date' => 'date',
'voided_at' => 'datetime',
];
/** 短期(临时)变价:有结束日期,到期后回到原来的长期报价。 */
public function isTemporary(): bool
{
return $this->end_date !== null;
}
/** 某一天是否在这一版的执行期间内(开始 ≤ 日期 ≤ 结束;长期版本没有结束)。 */
public function coversDate(\DateTimeInterface $date): bool
{
$day = \Illuminate\Support\Carbon::instance($date)->startOfDay();
return $this->effective_date !== null
&& $this->effective_date->lte($day)
&& ($this->end_date === null || $this->end_date->gte($day));
}
/** 执行期间文字,如「2026-10-12 至 2026-10-15(临时)」或「2026-10-01 起」。 */
public function periodLabel(): string
{
if ($this->effective_date === null) {
return '-';
}
return $this->end_date !== null
? $this->effective_date->toDateString() . ' 至 ' . $this->end_date->toDateString() . '(临时)'
: $this->effective_date->toDateString() . ' 起';
}
public function isVoided(): bool
{
return $this->status === self::STATUS_VOIDED;
}
public function change(): BelongsTo
{
return $this->belongsTo(CourierQuoteChange::class, 'source_change_id');

View File

@ -172,6 +172,39 @@ final class EsignClient
return $payload;
}
/**
* 获取签署人的签署页面链接(免登录,扫码或点开即可签署)。企业签署时带上企业名称,直接进入以该企业身份签署的页面。
*/
public function signUrl(string $signFlowId, string $psnAccount, ?string $orgName = null): string
{
$payload = [
'needLogin' => false,
'urlType' => 2,
'operator' => ['psnAccount' => $psnAccount],
'clientType' => 'ALL',
];
if ($orgName !== null && $orgName !== '') {
$payload['organization'] = ['orgName' => $orgName];
}
$data = $this->request('POST', "/v3/sign-flow/{$signFlowId}/sign-url", $payload);
$url = (string) ($data['shortUrl'] ?? '') ?: (string) ($data['url'] ?? '');
if ($url === '') {
throw new RuntimeException('E 签宝未返回签署链接。');
}
return $url;
}
/** 撤回还没签完的签署流程(签署人那边的待签任务随之失效)。 */
public function revokeSignFlow(string $signFlowId, string $reason): void
{
$this->request('POST', "/v3/sign-flow/{$signFlowId}/revoke", [
'revokeReason' => mb_substr($reason, 0, 50),
]);
}
public function signFlowDetail(string $signFlowId): array
{
return $this->request('GET', "/v3/sign-flow/{$signFlowId}/detail");

View File

@ -0,0 +1,800 @@
<?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
{
$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
{
$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
{
$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
{
$this->assertStatus($onboarding, [CourierOnboarding::STATUS_FIRST_APPROVED], '只有初审通过的申请可以复审。');
if ((int) $onboarding->first_reviewed_by === $userId && ! config('courier_onboarding.allow_same_reviewer')) {
throw ValidationException::withMessages(['approval' => '初审人不能同时做财务复审,请换一位管理员复审。']);
}
// 系统快递名称在生成二维码时由我方定好;复审只确认,不再按快递填写的内容推断。
$courierName = trim((string) ($data['courier_name'] ?? '')) ?: trim((string) $onboarding->courier_name);
$this->assertCourierNameAvailable($onboarding, $courierName);
return DB::transaction(function () use ($onboarding, $data, $courierName, $userId): CourierOnboarding {
$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);
});
}
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;
}
}

View File

@ -120,7 +120,8 @@ final class CourierQuoteChangeService
throw ValidationException::withMessages(['status' => '只有待审核的变更单可以审核通过。']);
}
if ($change->created_by === $userId) {
// 测试环境可用 COURIER_ONBOARDING_ALLOW_SAME_REVIEWER=true 放开(一人走完流程),正式环境保持发起人不能自审。
if ($change->created_by === $userId && ! config('courier_onboarding.allow_same_reviewer')) {
throw ValidationException::withMessages(['approval' => '发起人不能审核自己提交的报价变更。']);
}
@ -128,8 +129,15 @@ final class CourierQuoteChangeService
return DB::transaction(function () use ($change, $source, $userId): CourierQuoteChange {
$values = $source['values'];
$formulas = $source['formulas'];
foreach ($change->items as $item) {
// 后台变价 / 重新报价:变价单里已经是完整的新报价矩阵。
if ($change->hasQuotePayload() && filled($change->proposed_values_json)) {
$values = json_decode((string) $change->proposed_values_json, true, 512, JSON_THROW_ON_ERROR);
$formulas = [];
}
foreach ($change->hasQuotePayload() ? [] : $change->items as $item) {
if (! array_key_exists($item->source_row, $values) || ! array_key_exists($item->source_column, $values[$item->source_row])) {
throw ValidationException::withMessages(['items' => "报价单元格 {$item->source_cell} 已不存在,请退回重新核对。"]);
}
@ -150,9 +158,10 @@ final class CourierQuoteChangeService
'carrier_name' => $source['company_name'],
'quote_type' => $source['quote_type'],
'effective_date' => $change->effective_date,
'end_date' => $change->end_date,
'source_range' => $source['source_range'],
'values_json' => json_encode($values, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'formulas_json' => json_encode($source['formulas'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'formulas_json' => json_encode($formulas, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'status' => 'approved',
'signature_status' => '未签署',
'created_by' => $change->created_by,
@ -282,6 +291,7 @@ final class CourierQuoteChangeService
return [
'change_type' => $change->change_type,
'effective_date' => $change->effective_date->toDateString(),
'end_date' => $change->end_date?->toDateString(),
'is_retrospective' => $change->isRetrospective(),
'recalculation_status' => $change->recalculation_status,
'recalculation_note' => $change->recalculation_note,

View File

@ -13,9 +13,19 @@ final class CourierQuoteConfirmation
$nextVersion = CourierQuoteVersion::query()
->where('carrier_name', $version->carrier_name)
->where('quote_type', $version->quote_type)
->where('status', 'approved')
->where('effective_date', '>', $version->effective_date)
->whereNull('end_date')
->orderBy('effective_date')
->first();
// 短期变价到期后恢复的长期报价(打印在确认单上,让快递知道到期后按哪份执行)。
$restore = $version->isTemporary()
? \App\Support\CourierRequoteService::resolveAt(
CourierQuoteVersion::query()->with('signature')->where('company_name', $version->company_name)->where('status', 'approved')->where('id', '!=', $version->id)->get(),
$version->end_date->copy()->addDay(),
false,
)
: null;
return [
'version_id' => $version->id,
@ -25,7 +35,13 @@ final class CourierQuoteConfirmation
'carrier_name' => $version->carrier_name,
'quote_type' => $version->quote_type,
'effective_date' => $version->effective_date?->toDateString(),
'expiry_date' => $nextVersion?->effective_date?->copy()->subDay()->toDateString() ?: '长期',
'expiry_date' => $version->isTemporary()
? $version->end_date->toDateString()
: ($nextVersion?->effective_date?->copy()->subDay()->toDateString() ?: '长期'),
'temporary_note' => $version->isTemporary()
? '本报价为短期(临时)报价,仅在 ' . $version->effective_date->toDateString() . ' 至 ' . $version->end_date->toDateString() . ' 期间执行;'
. $version->end_date->copy()->addDay()->toDateString() . ' 起自动恢复' . ($restore ? '原报价 ' . $restore->version_code . '(' . $restore->effective_date->toDateString() . ' 起执行)' : '原长期报价') . ',无需另行签署。'
: null,
'face_fee' => $this->matrixMetric($values, ['面单费', '面单费用', '面单']),
'bubble_coefficient' => $this->matrixMetric($values, ['计泡系数', '泡系数', '计泡']),
'values' => $values,

View File

@ -23,6 +23,7 @@ final class CourierQuotePdf
'formulas' => $data['formulas'],
'partyA' => $data['party_a'],
'expiryDate' => $data['expiry_date'],
'temporaryNote' => $data['temporary_note'] ?? null,
'faceFee' => $data['face_fee'],
'bubbleCoefficient' => $data['bubble_coefficient'],
'signatureDate' => $signatureDate,

View File

@ -96,13 +96,23 @@ final class CourierQuoteSignatureService
$bytes = Storage::disk('uploads')->get($signature->source_pdf_path);
$fileName = 'quote-' . $signature->version->version_code . '.pdf';
$fileId = $client->uploadPdf($bytes, $fileName);
$signFlowId = $client->createSignFlow($fileId, $fileName, [
$signer = [
'flow_title' => $signature->version->carrier_name . ' ' . $signature->version->version_code . ' 报价确认',
'organization_name' => $signature->organization_name,
'operator_name' => $signature->operator_name,
'operator_account' => $signature->operator_account,
'notice_types' => $signature->notice_types,
], $signature->expires_at->getTimestampMs());
];
if (($data['signer_type'] ?? 'organization') === 'person') {
// 个人快递以本人身份签署,不走企业经办人。
$signer['signers'] = [[
'signer_type' => 0,
'operator_name' => $signature->operator_name,
'operator_account' => $signature->operator_account,
'notice_types' => $signature->notice_types,
]];
}
$signFlowId = $client->createSignFlow($fileId, $fileName, $signer, $signature->expires_at->getTimestampMs());
$signature->update([
'status' => CourierQuoteSignature::STATUS_SIGNING,
@ -139,6 +149,26 @@ final class CourierQuoteSignatureService
return $signature;
}
// 同一份确认单同时只查一次:几个页面同时打开时,避免后到的查询把已归档的状态又写回「签署完成,待归档」。
$lock = \Illuminate\Support\Facades\Cache::lock('esign-quote-sync-' . $signature->id, 120);
if (! $lock->get()) {
return $signature->fresh() ?? $signature;
}
try {
$signature = $signature->fresh() ?? $signature;
if ($signature->status === CourierQuoteSignature::STATUS_ARCHIVED) {
return $signature;
}
return $this->synchronizeUnlocked($signature, $userId);
} finally {
$lock->release();
}
}
private function synchronizeUnlocked(CourierQuoteSignature $signature, int $userId): CourierQuoteSignature
{
try {
$client = app(EsignClient::class);
$detail = $client->signFlowDetail($signature->esign_sign_flow_id);
@ -162,6 +192,52 @@ final class CourierQuoteSignatureService
}
}
/**
* 打开报价页面时顺手查一下还在签署中的报价确认单(未开启定时轮询、回调又到不了本系统时,
* 签完能尽快显示「已签署并归档」)。每次最多查几条,距上次查询不足 $minAgeSeconds 秒的跳过,失败不影响页面。
*/
public function refreshPending(int $limit = 5, int $minAgeSeconds = 60): int
{
if (! EsignClient::configured() || ! \Illuminate\Support\Facades\Schema::hasTable('business_courier_quote_signatures')) {
return 0;
}
// 已归档但版本上的文字状态没跟上的,补成「已签署并归档」。
$archivedVersionIds = CourierQuoteSignature::query()
->where('status', CourierQuoteSignature::STATUS_ARCHIVED)
->pluck('quote_version_id');
if ($archivedVersionIds->isNotEmpty()) {
CourierQuoteVersion::query()
->whereIn('id', $archivedVersionIds->all())
->where('signature_status', '<>', '已签署并归档')
->update(['signature_status' => '已签署并归档']);
}
$environment = strtolower(trim((string) config('services.esign.environment', 'sandbox')));
$cutoff = now()->subSeconds($minAgeSeconds);
$pending = CourierQuoteSignature::query()
->where('environment', $environment)
->whereNotNull('esign_sign_flow_id')
->where('esign_sign_flow_id', '<>', '')
->whereIn('status', [CourierQuoteSignature::STATUS_SIGNING, CourierQuoteSignature::STATUS_COMPLETED])
->where(fn ($query) => $query->whereNull('last_synced_at')->orWhere('last_synced_at', '<=', $cutoff))
->orderBy('last_synced_at')
->limit($limit)
->get();
$refreshed = 0;
foreach ($pending as $signature) {
try {
$this->synchronize($signature, (int) ($signature->updated_by ?: $signature->created_by));
$refreshed++;
} catch (Throwable) {
// 查询失败已记在 last_error,页面照常显示,可手动「同步签署状态」。
}
}
return $refreshed;
}
public function synchronizeById(int $signatureId): void
{
$signature = CourierQuoteSignature::allCompanies()->find($signatureId);

View File

@ -0,0 +1,293 @@
<?php
namespace App\Support;
/**
* 快递入驻报价的计费结构。
*
* 由现有约 110 张报价表归纳出 4 种常规计费方式,另有「其他」(按条码、全包单价、
* 城市流向等)由快递上传报价表、我方录入:
*
* - waybill_rate 面单 + 费率×重量(费率可按总重量所在区间分段)
* - first_additional 首重 + 续重×(重量−首重)(续重可分段)
* - flat_bands 按重量段一口价(只设一段即纯一口价)
* - flat_then 小重量段一口价,超出部分按「面单+费率」或「首重+续重」
* - other 其他,上传报价表
*
* 每个省份一行,列由结构决定:一口价各段 flat[]、面单/首重价 base、费率/续重各段 rates[]。
*/
final class CourierQuoteStructure
{
public const MODES = [
'waybill_rate' => '面单 + 费率×重量',
'first_additional' => '首重 + 续重',
'flat_bands' => '按重量段一口价',
'flat_then' => '小重量一口价 + 超出另算',
'other' => '其他(上传报价表)',
];
public const MODE_HINTS = [
'waybill_rate' => '运费 = 面单 + 重量 × 费率;费率可按重量区间分段,如 0-35kg / 35-50kg / 50kg 以上',
'first_additional' => '运费 = 首重价 +(重量 − 首重公斤数)× 续重;首重内按首重价',
'flat_bands' => '可设多档,如 0-1kg、1-2kg、2-3kg 各一个固定价;只设一档就是纯一口价,最后一档以上不收',
'flat_then' => '如 0-1、1-2、2-3kg 各档一口价,3kg 以上改按 面单+费率×重量 或 首重+续重',
'other' => '按条码、含货全包、按城市流向等特殊报价:直接上传报价表(Excel 或照片),由我方录入',
];
public const ROUNDING = [
'none' => '不取整',
'ceil' => '向上取整(如 2.1kg 按 3kg)',
'round' => '四舍五入(如 4.4kg 按 4kg,4.5kg 按 5kg)',
];
public const MAX_BANDS = 12;
public const MAX_TIERS = 6;
/**
* @param array<string,mixed> $input
* @return array{mode:string,flat_bands:list<float>,then_mode:string,first_weight:?float,rate_tiers:list<float>}
*/
public static function normalize(array $input): array
{
$mode = array_key_exists((string) ($input['quote_mode'] ?? ''), self::MODES) ? (string) $input['quote_mode'] : 'waybill_rate';
$thenMode = ($input['quote_then_mode'] ?? '') === 'first_additional' ? 'first_additional' : 'waybill_rate';
return [
'mode' => $mode,
'flat_bands' => in_array($mode, ['flat_bands', 'flat_then'], true)
? array_slice(self::parseList($input['quote_flat_bands'] ?? ''), 0, self::MAX_BANDS)
: [],
'then_mode' => $mode === 'flat_then' ? $thenMode : '',
'first_weight' => self::usesFirstWeight($mode, $thenMode) && is_numeric($input['quote_first_weight'] ?? null)
? round((float) $input['quote_first_weight'], 2)
: null,
'rate_tiers' => self::usesRates($mode)
? array_slice(self::parseList($input['quote_rate_tiers'] ?? ''), 0, self::MAX_TIERS)
: [],
];
}
/**
* 解析「3,4,5」「3、4、5」这类重量节点,去重并升序。
*
* @return list<float>
*/
public static function parseList(mixed $value): array
{
if (is_array($value)) {
$parts = $value;
} else {
$parts = preg_split('/[\s,,、;;\/]+/u', (string) $value) ?: [];
}
$numbers = [];
foreach ($parts as $part) {
$part = preg_replace('/(kg|KG|公斤)$/u', '', trim((string) $part));
if (is_numeric($part) && (float) $part > 0) {
$numbers[] = round((float) $part, 2);
}
}
$numbers = array_values(array_unique($numbers, SORT_REGULAR));
sort($numbers);
return $numbers;
}
/**
* @param array<string,mixed> $structure
* @return list<array{key:string,label:string}>
*/
public static function columns(array $structure): array
{
$mode = $structure['mode'] ?? 'waybill_rate';
if ($mode === 'other') {
return [];
}
$columns = [];
$bands = $structure['flat_bands'] ?? [];
if (in_array($mode, ['flat_bands', 'flat_then'], true)) {
$lower = 0.0;
foreach ($bands as $index => $upper) {
$columns[] = ['key' => "flat.{$index}", 'label' => self::kg($lower) . '-' . self::kg($upper) . 'kg(含' . self::kg($upper) . ') 一口价'];
$lower = $upper;
}
}
if (! self::usesRates($mode)) {
return $columns;
}
$thenMode = $mode === 'flat_then' ? ($structure['then_mode'] ?? 'waybill_rate') : $mode;
$prefix = $mode === 'flat_then' && $bands !== [] ? self::kg((float) end($bands)) . 'kg 以上 ' : '';
if ($thenMode === 'first_additional') {
$columns[] = ['key' => 'base', 'label' => $prefix . '首重' . self::kg((float) ($structure['first_weight'] ?? 0)) . 'kg(元)'];
$rateName = '续重';
} else {
$columns[] = ['key' => 'base', 'label' => $prefix . '面单'];
$rateName = '费率';
}
$tiers = $structure['rate_tiers'] ?? [];
if ($tiers === []) {
$columns[] = ['key' => 'rates.0', 'label' => "{$rateName}(元/kg)"];
return $columns;
}
foreach (array_values($tiers) as $index => $edge) {
$lower = $index === 0 ? ($mode === 'flat_then' && $bands !== [] ? (float) end($bands) : 0.0) : (float) $tiers[$index - 1];
$label = self::kg($lower) . '-' . self::kg($edge) . 'kg';
$columns[] = ['key' => "rates.{$index}", 'label' => "{$rateName} {$label}"];
}
$columns[] = ['key' => 'rates.' . count($tiers), 'label' => "{$rateName} " . self::kg((float) end($tiers)) . 'kg以上'];
return $columns;
}
/**
* 按列顺序取一行的值。
*
* @param array<string,mixed> $row
* @param list<array{key:string,label:string}> $columns
* @return list<?float>
*/
public static function rowValues(array $row, array $columns): array
{
return array_map(static function (array $column) use ($row): ?float {
$value = data_get($row, $column['key']);
return is_numeric($value) ? (float) $value : null;
}, $columns);
}
/**
* 只保留填了价格的省份行,并按当前结构裁剪列。
*
* @param array<int|string,mixed> $rows
* @param array<string,mixed> $structure
* @return list<array{region:string,flat:list<?float>,base:?float,rates:list<?float>}>
*/
public static function normalizeRows(array $rows, array $structure): array
{
$flatCount = in_array($structure['mode'] ?? '', ['flat_bands', 'flat_then'], true) ? count($structure['flat_bands'] ?? []) : 0;
$rateCount = self::usesRates($structure['mode'] ?? '') ? count($structure['rate_tiers'] ?? []) + 1 : 0;
$number = static fn ($value): ?float => is_numeric($value) && (float) $value >= 0 ? round((float) $value, 4) : null;
$result = [];
foreach ($rows as $row) {
if (! is_array($row)) {
continue;
}
$region = mb_substr(trim((string) ($row['region'] ?? '')), 0, 50);
$flat = [];
for ($i = 0; $i < $flatCount; $i++) {
$flat[] = $number(data_get($row, "flat.{$i}"));
}
$rates = [];
for ($i = 0; $i < $rateCount; $i++) {
$rates[] = $number(data_get($row, "rates.{$i}"));
}
$base = $rateCount > 0 ? $number($row['base'] ?? null) : null;
$filled = array_filter([...$flat, $base, ...$rates], static fn ($value): bool => $value !== null);
if ($region === '' || $filled === []) {
continue;
}
$result[] = ['region' => $region, 'flat' => $flat, 'base' => $base, 'rates' => $rates];
}
return $result;
}
/**
* 提交前的结构和逐行完整性检查,返回 字段 => 错误信息。
*
* @param array<string,mixed> $structure
* @param list<array<string,mixed>> $rows
* @return array<string,string>
*/
public static function validate(array $structure, array $rows): array
{
$mode = $structure['mode'] ?? '';
if ($mode === 'other') {
return [];
}
$errors = [];
if (in_array($mode, ['flat_bands', 'flat_then'], true) && ($structure['flat_bands'] ?? []) === []) {
$errors['quote_flat_bands'] = '请至少设置一档一口价重量,如 0-3kg,或 0-1、1-2、2-3kg 三档。';
}
if (self::usesFirstWeight($mode, $structure['then_mode'] ?? '') && ! ((float) ($structure['first_weight'] ?? 0) > 0)) {
$errors['quote_first_weight'] = '请填写首重公斤数,如 3 或 5。';
}
if ($mode === 'flat_then' && ($structure['flat_bands'] ?? []) !== [] && ($structure['rate_tiers'] ?? []) !== []
&& min($structure['rate_tiers']) <= max($structure['flat_bands'])) {
$errors['quote_rate_tiers'] = '超出部分的费率分段应大于一口价的最后一段重量。';
}
if ($errors !== []) {
return $errors;
}
if ($rows === []) {
return ['quote_rows' => '请至少填写一个省份的报价,系统会据此生成报价确认单发到 e签宝签署。'];
}
$columns = self::columns($structure);
foreach ($rows as $row) {
$values = self::rowValues($row, $columns);
$missing = [];
foreach ($values as $index => $value) {
if ($value === null) {
$missing[] = $columns[$index]['label'];
}
}
if ($missing !== []) {
$errors['quote_rows'] = "{$row['region']}还有价格没填:" . implode('、', $missing) . '。不发的省份整行留空即可。';
break;
}
}
return $errors;
}
/**
* 计费方式的文字说明,用于报价矩阵、PDF 和后台展示。
*
* @param array<string,mixed> $structure
*/
public static function describe(array $structure): string
{
$mode = $structure['mode'] ?? 'waybill_rate';
$bands = $structure['flat_bands'] ?? [];
$tierNote = ($structure['rate_tiers'] ?? []) !== [] ? '(按总重量所在区间取费率)' : '';
$fw = self::kg((float) ($structure['first_weight'] ?? 0));
return match ($mode) {
'first_additional' => "首重{$fw}kg内按首重价,超出:首重价+(重量-{$fw})*续重{$tierNote}",
'flat_bands' => '按重量段一口价,' . self::kg((float) (end($bands) ?: 0)) . 'kg以上不在报价范围',
'flat_then' => self::kg((float) (end($bands) ?: 0)) . 'kg以内按段一口价,超出:'
. (($structure['then_mode'] ?? '') === 'first_additional'
? "首重{$fw}kg价+(重量-{$fw})*续重"
: '面单+重量*费率') . $tierNote,
'other' => '特殊计费,见附件报价表',
default => '面单+重量*费率' . $tierNote,
};
}
public static function usesRates(string $mode): bool
{
return in_array($mode, ['waybill_rate', 'first_additional', 'flat_then'], true);
}
public static function usesFirstWeight(string $mode, string $thenMode = ''): bool
{
return $mode === 'first_additional' || ($mode === 'flat_then' && $thenMode === 'first_additional');
}
public static function kg(float $value): string
{
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
}
}

View File

@ -0,0 +1,483 @@
<?php
namespace App\Support;
use App\Models\Business\CourierOnboarding;
use App\Models\Business\CourierQuoteChange;
use App\Models\Business\CourierQuoteChangeEvent;
use App\Models\Business\CourierQuoteVersion;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
/**
* 已入驻快递的后台变价:我方在入驻同款报价页里改价格或计费方式,
* 提交成报价变更单 → 审核通过生成新版本 → 按入驻资料推送给快递 e签宝签署 → 签完回传归档。
* 同一家快递始终只有一条入驻记录,报价按版本往后接。
*/
final class CourierRequoteService
{
public const CHANGE_PRICE = '变价';
public const CHANGE_STRUCTURE = '重新报价';
/** 单项涨跌超过 30% 在审核页提醒。 */
public const LARGE_CHANGE = 0.3;
/** 报价管理里的报价类型:区分入驻首版和后续变价。 */
public static function quoteTypeFor(string $changeType): string
{
return $changeType === self::CHANGE_STRUCTURE ? '快递重新报价' : '快递变价报价';
}
public function __construct(private readonly CourierOnboardingService $onboardings)
{
}
/** 报价版本属于哪家已入驻快递(按系统快递名称对应)。 */
public function onboardingForCourier(?string $courierName): ?CourierOnboarding
{
if (blank($courierName)) {
return null;
}
return CourierOnboarding::query()
->where('courier_name', $courierName)
->whereIn('status', [CourierOnboarding::STATUS_APPROVED, CourierOnboarding::STATUS_ACTIVATED])
->latest('id')
->first();
}
/**
* 已入驻快递的系统名称 => 入驻 id,报价管理列表一次取出。
*
* @return array<string,int>
*/
public function onboardedCourierIds(): array
{
return CourierOnboarding::query()
->whereIn('status', [CourierOnboarding::STATUS_APPROVED, CourierOnboarding::STATUS_ACTIVATED])
->whereNotNull('courier_name')
->orderBy('id')
->pluck('id', 'courier_name')
->all();
}
/** 这家快递最新的报价版本(含待签署的)。 */
public function latestVersion(CourierOnboarding $onboarding): ?CourierQuoteVersion
{
return CourierQuoteVersion::query()
->with('signature')
->where('company_name', $onboarding->courier_name)
->where('status', 'approved')
->latest('id')
->first();
}
/** @return \Illuminate\Support\Collection<int,CourierQuoteVersion> */
public function versions(CourierOnboarding $onboarding): \Illuminate\Support\Collection
{
return CourierQuoteVersion::query()
->with('signature')
->where('company_name', $onboarding->courier_name)
->where('status', 'approved')
->latest('id')
->get();
}
/** 还没审核完的变价单(待审核或已退回),同一时间只允许一张。 */
public function openChange(CourierOnboarding $onboarding): ?CourierQuoteChange
{
return CourierQuoteChange::query()
->where('carrier_name', $onboarding->courier_name)
->whereIn('status', [CourierQuoteChange::STATUS_PENDING_REVIEW, CourierQuoteChange::STATUS_RETURNED, CourierQuoteChange::STATUS_DRAFT])
->whereNotNull('quote_payload')
->latest('id')
->first();
}
/**
* 当前生效(最新)版本的报价结构,作为变价底稿。
*
* @return array<string,mixed>
*/
public function currentPayload(CourierOnboarding $onboarding): array
{
// 以最新的长期报价为底稿:临时变价到期就作废,不该成为下一次变价的起点。
$version = $this->versions($onboarding)->first(static fn (CourierQuoteVersion $item): bool => ! $item->isTemporary())
?? $this->latestVersion($onboarding);
$change = $version?->source_change_id ? CourierQuoteChange::query()->find($version->source_change_id) : null;
return $change?->hasQuotePayload() ? $change->quote_payload : $this->onboardings->quotePayload($onboarding);
}
/** 用报价结构生成一份临时的入驻对象,复用入驻的报价列、规则文字和报价矩阵。 */
public function draftModel(CourierOnboarding $onboarding, array $payload): CourierOnboarding
{
$model = $onboarding->replicate();
$model->id = $onboarding->id;
foreach (CourierOnboardingService::QUOTE_FIELDS as $field) {
$model->setAttribute($field, $payload[$field] ?? null);
}
$model->setRelation('files', collect());
return $model;
}
/**
* 提交变价单(新建或更新已退回的那张),进入待审核。
*
* @param array<string,mixed> $data
*/
public function submit(CourierOnboarding $onboarding, array $data, int $userId): CourierQuoteChange
{
if ($onboarding->status !== CourierOnboarding::STATUS_ACTIVATED) {
throw ValidationException::withMessages(['status' => '快递开通后才能发起变价。']);
}
$attributes = $this->onboardings->quoteAttributes($data);
$structure = $attributes['quote_structure'];
if (($structure['mode'] ?? '') === 'other') {
throw ValidationException::withMessages(['quote_mode' => '后台变价请选择具体的计费方式。']);
}
$errors = CourierQuoteStructure::validate($structure, $attributes['quote_rows']);
if (blank($attributes['quote_effective_date'])) {
$errors['quote_effective_date'] = '请填写新报价的执行日期。';
}
// 结束日期选填:填了就是短期(临时)变价,到期后自动回到原来的长期报价。
$endDate = filled($data['quote_end_date'] ?? null) ? Carbon::parse((string) $data['quote_end_date'])->startOfDay() : null;
if ($endDate !== null && filled($attributes['quote_effective_date']) && $endDate->lt(Carbon::parse($attributes['quote_effective_date'])->startOfDay())) {
$errors['quote_end_date'] = '结束日期不能早于执行日期。';
}
if ($errors !== []) {
throw ValidationException::withMessages($errors);
}
$payload = [];
foreach (CourierOnboardingService::QUOTE_FIELDS as $field) {
$payload[$field] = $attributes[$field] ?? null;
}
$current = $this->currentPayload($onboarding);
if ($this->comparable($payload) === $this->comparable($current)) {
throw ValidationException::withMessages(['quote_rows' => '报价和当前版本完全一样,没有需要变更的内容。']);
}
$open = $this->openChange($onboarding);
if ($open !== null && $open->status === CourierQuoteChange::STATUS_PENDING_REVIEW) {
throw ValidationException::withMessages(['status' => "已有待审核的变价单 {$open->number},审核完成后再发起新的变价。"]);
}
$version = $this->latestVersion($onboarding);
$effectiveDate = Carbon::parse($payload['quote_effective_date'])->startOfDay();
$draft = $this->draftModel($onboarding, $payload);
$values = $this->onboardings->quoteMatrix($draft, $draft->quoteRowList(), $effectiveDate);
$type = $this->sameStructure($payload, $current) ? self::CHANGE_PRICE : self::CHANGE_STRUCTURE;
return DB::transaction(function () use ($onboarding, $data, $userId, $payload, $values, $version, $effectiveDate, $endDate, $type, $open): CourierQuoteChange {
$attributes = [
'source_quote_id' => $version?->version_code ?? ('RZ-' . $onboarding->id),
'source_sheet_name' => $version?->sheet_name ?? ($onboarding->courier_name . '报价'),
'carrier_name' => $onboarding->courier_name,
'quote_type' => $version?->quote_type ?? '快递报价',
'change_type' => $type,
'status' => CourierQuoteChange::STATUS_PENDING_REVIEW,
'effective_date' => $effectiveDate,
'end_date' => $endDate,
'is_retrospective' => $effectiveDate->lt(today()),
'recalculation_status' => CourierQuoteChange::RECALCULATION_NOT_REQUIRED,
'summary' => trim((string) $data['summary']),
'submitted_by' => $userId,
'submitted_at' => now(),
'quote_payload' => $payload,
'proposed_values_json' => json_encode($values, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
];
if ($open !== null) {
$from = $open->status;
$open->update($attributes);
$change = $open;
} else {
$from = null;
$change = CourierQuoteChange::create([
'company_id' => company_id(),
'number' => $this->nextChangeNumber(),
'created_by' => $userId,
...$attributes,
]);
}
CourierQuoteChangeEvent::create([
'company_id' => company_id(),
'quote_change_id' => $change->id,
'event_type' => 'submitted',
'from_status' => $from,
'to_status' => CourierQuoteChange::STATUS_PENDING_REVIEW,
'note' => "后台{$type}:{$onboarding->courier_name},自 {$effectiveDate->toDateString()} 起执行。",
'created_by' => $userId,
]);
return $change->fresh();
});
}
/**
* 新旧报价对比:同一计费方式时逐格列出变动;计费方式变了则说明结构变化。
*
* @return array{same_structure:bool,old_mode:string,new_mode:string,old_columns:list<string>,new_columns:list<string>,cells:list<array{region:string,label:string,old:?float,new:?float}>,added:list<string>,removed:list<string>,rules:list<array{old:string,new:string}>}
*/
public function diff(CourierOnboarding $onboarding, array $old, array $new): array
{
$oldModel = $this->draftModel($onboarding, $old);
$newModel = $this->draftModel($onboarding, $new);
$oldColumns = $oldModel->quoteColumns();
$newColumns = $newModel->quoteColumns();
$oldRows = collect($oldModel->quoteRowList())->keyBy('region');
$newRows = collect($newModel->quoteRowList())->keyBy('region');
$same = $this->sameStructure($new, $old);
$cells = [];
if ($same) {
foreach ($newRows as $region => $row) {
if (! $oldRows->has($region)) {
continue;
}
$before = CourierQuoteStructure::rowValues($oldRows->get($region), $oldColumns);
$after = CourierQuoteStructure::rowValues($row, $newColumns);
foreach ($newColumns as $index => $column) {
if (round((float) ($before[$index] ?? 0), 4) !== round((float) ($after[$index] ?? 0), 4) || ($before[$index] === null) !== ($after[$index] === null)) {
$cells[] = ['region' => (string) $region, 'label' => $column['label'], 'old' => $before[$index], 'new' => $after[$index]];
}
}
}
}
$oldRules = $oldModel->quoteRuleLines();
$newRules = $newModel->quoteRuleLines();
$rules = [];
foreach (array_unique([...array_keys($oldRules), ...array_keys($newRules)]) as $index) {
if (($oldRules[$index] ?? '') !== ($newRules[$index] ?? '')) {
$rules[] = ['old' => (string) ($oldRules[$index] ?? '—'), 'new' => (string) ($newRules[$index] ?? '—')];
}
}
if (($old['quote_note'] ?? null) !== ($new['quote_note'] ?? null)) {
$rules[] = ['old' => '备注:' . ($old['quote_note'] ?: '无'), 'new' => '备注:' . ($new['quote_note'] ?: '无')];
}
return [
'same_structure' => $same,
'old_mode' => CourierQuoteStructure::describe($oldModel->quoteStructure()),
'new_mode' => CourierQuoteStructure::describe($newModel->quoteStructure()),
'old_columns' => array_column($oldColumns, 'label'),
'new_columns' => array_column($newColumns, 'label'),
'cells' => $cells,
'added' => $newRows->keys()->diff($oldRows->keys())->values()->all(),
'removed' => $oldRows->keys()->diff($newRows->keys())->values()->all(),
'rules' => $rules,
];
}
/** 变价单对应的底稿(变价前的版本)报价结构。 */
public function basePayload(CourierOnboarding $onboarding, CourierQuoteChange $change): array
{
$source = CourierQuoteVersion::query()->where('version_code', $change->source_quote_id)->first();
$sourceChange = $source?->source_change_id ? CourierQuoteChange::query()->find($source->source_change_id) : null;
return $sourceChange?->hasQuotePayload() ? $sourceChange->quote_payload : $this->onboardings->quotePayload($onboarding);
}
/**
* 作废发错的报价版本。还没签完的同时撤回 e签宝 签署任务;已签完的只做内部作废(原件、回签单保留),
* 需另发一版正确报价让快递重新签。
*
* @return string 给操作人看的结果说明
*/
public function voidVersion(CourierQuoteVersion $version, string $reason, int $userId): string
{
if ($version->isVoided()) {
throw ValidationException::withMessages(['void_reason' => "{$version->version_code} 已经作废。"]);
}
$signature = $version->signature()->first();
$signed = $signature !== null && in_array($signature->status, [
\App\Models\Business\CourierQuoteSignature::STATUS_ARCHIVED,
\App\Models\Business\CourierQuoteSignature::STATUS_COMPLETED,
], true);
$note = '';
if ($signature !== null && ! $signed && filled($signature->esign_sign_flow_id)
&& $signature->status === \App\Models\Business\CourierQuoteSignature::STATUS_SIGNING) {
try {
app(\App\Services\Esign\EsignClient::class)->revokeSignFlow((string) $signature->esign_sign_flow_id, '报价发错作废:' . $reason);
} catch (\Throwable $exception) {
throw ValidationException::withMessages(['void_reason' => '撤回 e签宝 签署任务失败:' . mb_substr($exception->getMessage(), 0, 200) . '。可先点「同步签署状态」确认快递是否已签。']);
}
$signature->update(['status' => \App\Models\Business\CourierQuoteSignature::STATUS_REVOKED, 'updated_by' => $userId]);
$note = '已撤回快递 e签宝 里的待签任务。';
} elseif ($signed) {
$note = '快递已签署,回签单保留;请另发一版正确报价让快递重新签署。';
}
DB::transaction(function () use ($version, $reason, $userId, $signature, $signed, $note): void {
$version->update([
'status' => CourierQuoteVersion::STATUS_VOIDED,
'signature_status' => $signed ? $version->signature_status : ($signature !== null ? '已撤销' : $version->signature_status),
'void_reason' => $reason,
'voided_at' => now(),
'voided_by' => $userId,
]);
if ($version->source_change_id) {
CourierQuoteChangeEvent::create([
'company_id' => $version->company_id,
'quote_change_id' => $version->source_change_id,
'event_type' => 'voided',
'from_status' => null,
'to_status' => null,
'note' => "报价版本 {$version->version_code} 已作废:{$reason}。{$note}",
'created_by' => $userId,
]);
}
});
return "{$version->version_code} 已作废,不再参与生效。" . $note;
}
/** 这家快递当前生效的版本:已签署、未作废、执行日期已到中最新的一版。 */
/**
* 某一天(默认今天)执行的报价版本:已签署、未作废、执行期间覆盖这一天,取开始日期最晚的一版。
* 短期变价期间用临时价,到期后自动回到原来的长期报价。
*/
public function effectiveVersion(string $courierName, ?\DateTimeInterface $date = null): ?CourierQuoteVersion
{
$versions = CourierQuoteVersion::query()
->with('signature')
->where('company_name', $courierName)
->where('status', CourierQuoteVersion::STATUS_APPROVED)
->get();
return self::resolveAt($versions, $date ?? today());
}
/**
* @param \Illuminate\Support\Collection<int,CourierQuoteVersion> $versions 同一家快递的版本
* @param bool $includeTemporary false 时只看长期报价(算「临时价到期后回到哪一版」)
*/
public static function resolveAt(\Illuminate\Support\Collection $versions, \DateTimeInterface $date, bool $includeTemporary = true): ?CourierQuoteVersion
{
return $versions
->filter(static fn (CourierQuoteVersion $version): bool => ! $version->isVoided()
&& self::isSignedVersion($version)
&& ($includeTemporary || ! $version->isTemporary())
&& $version->coversDate($date))
->sortBy([
static fn ($a, $b) => $b->effective_date <=> $a->effective_date,
static fn ($a, $b) => $b->id <=> $a->id,
])
->first();
}
public static function isSignedVersion(CourierQuoteVersion $version): bool
{
return $version->signature_status === '已签署并归档'
|| $version->signature?->status === \App\Models\Business\CourierQuoteSignature::STATUS_ARCHIVED;
}
/**
* 审核提醒:倒签、价格为 0、涨跌幅过大。
*
* @param array<string,mixed>|null $diff
* @return list<string>
*/
public function reviewWarnings(CourierQuoteChange $change, ?array $diff): array
{
$warnings = [];
$current = $this->effectiveVersion((string) $change->carrier_name);
if ($current !== null && $change->effective_date->lt($current->effective_date)) {
$warnings[] = "执行日期 {$change->effective_date->toDateString()} 早于当前生效版本 {$current->version_code}({$current->effective_date->toDateString()} 起),属于倒签,会改动已发生运单的运费。";
} elseif ($change->effective_date->lt(today())) {
$warnings[] = "执行日期 {$change->effective_date->toDateString()} 早于今天,属于倒签,会改动已发生运单的运费。";
}
if ($change->end_date !== null) {
$others = CourierQuoteVersion::query()
->where('company_name', (string) $change->carrier_name)
->where('status', CourierQuoteVersion::STATUS_APPROVED)
->where(static fn ($query) => $query->whereNull('source_change_id')->orWhere('source_change_id', '!=', $change->id))
->get();
$base = self::resolveAt($others, $change->end_date->copy()->addDay(), false);
$warnings[] = $base !== null
? "这是短期变价:{$change->effective_date->toDateString()} 至 {$change->end_date->toDateString()} 执行,{$change->end_date->copy()->addDay()->toDateString()} 起自动恢复 {$base->version_code}({$base->periodLabel()})。"
: "这是短期变价,但到期后({$change->end_date->copy()->addDay()->toDateString()} 起)没有可以恢复的已签署长期报价,到期后这家快递将没有生效报价。";
$overlap = $others->filter(static fn (CourierQuoteVersion $version): bool => ! $version->isVoided() && $version->isTemporary()
&& $version->effective_date->lte($change->end_date) && $version->end_date->gte($change->effective_date));
if ($overlap->isNotEmpty()) {
$warnings[] = '和已有的短期变价执行期间重叠:' . $overlap->map(static fn (CourierQuoteVersion $version): string => $version->version_code . '(' . $version->periodLabel() . ')')->implode('、') . ',重叠的日期以开始日期较晚的一份为准。';
}
}
$big = [];
foreach ($diff['cells'] ?? [] as $cell) {
$old = $cell['old'];
$new = $cell['new'];
if ($old !== null && $new !== null && (float) $old > 0 && abs((float) $new - (float) $old) / (float) $old > self::LARGE_CHANGE) {
$big[] = $cell['region'] . ' ' . $cell['label'] . ' ' . CourierQuoteStructure::kg((float) $old) . '→' . CourierQuoteStructure::kg((float) $new);
}
}
if ($big !== []) {
$warnings[] = '涨跌超过 ' . (int) (self::LARGE_CHANGE * 100) . '%:' . implode(';', array_slice($big, 0, 8)) . (count($big) > 8 ? ' 等 ' . count($big) . ' 处' : '') . ',请核对是否多打或少打了位数。';
}
$zeros = [];
$table = \App\Http\Controllers\Business\CourierQuoteSignatures::quoteTable(json_decode((string) $change->proposed_values_json, true) ?: []);
foreach (array_slice($table, 1) as $row) {
foreach (array_slice($row, 1, null, true) as $index => $value) {
if (is_numeric($value) && (float) $value === 0.0) {
$zeros[] = $row[0] . ' ' . ($table[0][$index] ?? '');
}
}
}
if ($zeros !== []) {
$warnings[] = '价格为 0:' . implode('、', array_slice($zeros, 0, 8)) . (count($zeros) > 8 ? ' 等 ' . count($zeros) . ' 处' : '') . '。';
}
return $warnings;
}
private function sameStructure(array $a, array $b): bool
{
$shape = static fn (array $payload): array => CourierQuoteStructure::columns(
is_array($payload['quote_structure'] ?? null) ? $payload['quote_structure'] : []
);
return $shape($a) === $shape($b);
}
private function comparable(array $payload): string
{
$rows = array_map(static fn (array $row): array => [
'region' => $row['region'] ?? '',
'flat' => array_map(static fn ($v) => $v === null ? null : round((float) $v, 4), (array) ($row['flat'] ?? [])),
'base' => isset($row['base']) ? round((float) $row['base'], 4) : null,
'rates' => array_map(static fn ($v) => $v === null ? null : round((float) $v, 4), (array) ($row['rates'] ?? [])),
], array_values(array_filter((array) ($payload['quote_rows'] ?? []), 'is_array')));
$copy = $payload;
$copy['quote_rows'] = $rows;
unset($copy['quote_effective_date']);
foreach (['min_weight', 'per_ticket_surcharge'] as $key) {
$copy[$key] = isset($copy[$key]) ? round((float) $copy[$key], 4) : null;
}
$copy['volumetric_divisor'] = isset($copy['volumetric_divisor']) ? (int) $copy['volumetric_divisor'] : null;
ksort($copy);
return json_encode($copy, JSON_UNESCAPED_UNICODE);
}
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);
}
}

500
app/Support/QrCode.php Normal file
View File

@ -0,0 +1,500 @@
<?php
namespace App\Support;
use InvalidArgumentException;
/**
* 纯 PHP 二维码生成(字节模式、M 级纠错,版本 1-40),输出 SVG。
*
* 只服务于站内生成邀请链接二维码这类短文本场景,避免为此引入第三方依赖。
* 编码流程遵循 ISO/IEC 18004:数据码字 → Reed-Solomon 纠错 → 分块交织 →
* 功能图形 + 数据放置 → 按罚分挑选掩码 → 写入格式/版本信息。
*/
final class QrCode
{
private const ECC_CODEWORDS_PER_BLOCK = [
-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26,
26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28,
];
private const NUM_ERROR_CORRECTION_BLOCKS = [
-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16,
17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49,
];
/** M 级纠错的格式位。 */
private const FORMAT_BITS_M = 0;
private int $version;
private int $size;
/** @var list<list<bool>> */
private array $modules = [];
/** @var list<list<bool>> */
private array $isFunction = [];
private function __construct(string $text)
{
$bytes = array_values(unpack('C*', $text) ?: []);
$this->version = $this->chooseVersion(count($bytes));
$this->size = $this->version * 4 + 17;
$row = array_fill(0, $this->size, false);
$this->modules = array_fill(0, $this->size, $row);
$this->isFunction = array_fill(0, $this->size, $row);
$this->drawFunctionPatterns();
$this->drawCodewords($this->addEccAndInterleave($this->dataCodewords($bytes)));
$this->applyBestMask();
}
public static function svg(string $text, int $scale = 8, int $quietZone = 4): string
{
if ($text === '') {
throw new InvalidArgumentException('二维码内容不能为空。');
}
$qr = new self($text);
$dimension = $qr->size + $quietZone * 2;
$path = '';
for ($y = 0; $y < $qr->size; $y++) {
for ($x = 0; $x < $qr->size; $x++) {
if ($qr->modules[$y][$x]) {
$path .= sprintf('M%d %dh1v1h-1z', $x + $quietZone, $y + $quietZone);
}
}
}
$pixels = $dimension * $scale;
return sprintf(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %1$d %1$d" width="%2$d" height="%2$d" shape-rendering="crispEdges">'
. '<rect width="%1$d" height="%1$d" fill="#ffffff"/><path d="%3$s" fill="#000000"/></svg>',
$dimension,
$pixels,
$path,
);
}
/**
* @return list<list<bool>> 供测试校验的模块矩阵(true 为黑)。
*/
public static function matrix(string $text): array
{
return (new self($text))->modules;
}
private function chooseVersion(int $byteCount): int
{
for ($version = 1; $version <= 40; $version++) {
$capacityBits = $this->numDataCodewords($version) * 8;
$usedBits = 4 + ($version < 10 ? 8 : 16) + $byteCount * 8;
if ($usedBits <= $capacityBits) {
return $version;
}
}
throw new InvalidArgumentException('二维码内容过长。');
}
/**
* @param list<int> $bytes
* @return list<int>
*/
private function dataCodewords(array $bytes): array
{
$bits = [];
$append = static function (int $value, int $length) use (&$bits): void {
for ($i = $length - 1; $i >= 0; $i--) {
$bits[] = ($value >> $i) & 1;
}
};
$append(0b0100, 4);
$append(count($bytes), $this->version < 10 ? 8 : 16);
foreach ($bytes as $byte) {
$append($byte, 8);
}
$capacityBits = $this->numDataCodewords($this->version) * 8;
$append(0, min(4, $capacityBits - count($bits)));
$append(0, (8 - count($bits) % 8) % 8);
$codewords = [];
foreach (array_chunk($bits, 8) as $chunk) {
$value = 0;
foreach ($chunk as $bit) {
$value = ($value << 1) | $bit;
}
$codewords[] = $value;
}
for ($pad = 0xEC; count($codewords) < $capacityBits / 8; $pad ^= 0xEC ^ 0x11) {
$codewords[] = $pad;
}
return $codewords;
}
/**
* @param list<int> $data
* @return list<int>
*/
private function addEccAndInterleave(array $data): array
{
$numBlocks = self::NUM_ERROR_CORRECTION_BLOCKS[$this->version];
$blockEccLength = self::ECC_CODEWORDS_PER_BLOCK[$this->version];
$rawCodewords = intdiv($this->numRawDataModules($this->version), 8);
$numShortBlocks = $numBlocks - $rawCodewords % $numBlocks;
$shortBlockLength = intdiv($rawCodewords, $numBlocks);
$divisor = $this->reedSolomonDivisor($blockEccLength);
$blocks = [];
$offset = 0;
for ($i = 0; $i < $numBlocks; $i++) {
$length = $shortBlockLength - $blockEccLength + ($i < $numShortBlocks ? 0 : 1);
$block = array_slice($data, $offset, $length);
$offset += $length;
$ecc = $this->reedSolomonRemainder($block, $divisor);
if ($i < $numShortBlocks) {
$block[] = 0;
}
$blocks[] = array_merge($block, $ecc);
}
$result = [];
$blockLength = count($blocks[0]);
for ($i = 0; $i < $blockLength; $i++) {
foreach ($blocks as $j => $block) {
if ($i !== $shortBlockLength - $blockEccLength || $j >= $numShortBlocks) {
$result[] = $block[$i];
}
}
}
return $result;
}
private function drawFunctionPatterns(): void
{
for ($i = 0; $i < $this->size; $i++) {
$this->setFunction(6, $i, $i % 2 === 0);
$this->setFunction($i, 6, $i % 2 === 0);
}
$this->drawFinder(3, 3);
$this->drawFinder($this->size - 4, 3);
$this->drawFinder(3, $this->size - 4);
$positions = $this->alignmentPositions();
$count = count($positions);
foreach ($positions as $i => $x) {
foreach ($positions as $j => $y) {
if (($i === 0 && $j === 0) || ($i === 0 && $j === $count - 1) || ($i === $count - 1 && $j === 0)) {
continue;
}
for ($dy = -2; $dy <= 2; $dy++) {
for ($dx = -2; $dx <= 2; $dx++) {
$this->setFunction($x + $dx, $y + $dy, max(abs($dx), abs($dy)) !== 1);
}
}
}
}
$this->drawFormatBits(0);
$this->drawVersion();
}
private function drawFinder(int $x, int $y): void
{
for ($dy = -4; $dy <= 4; $dy++) {
for ($dx = -4; $dx <= 4; $dx++) {
$xx = $x + $dx;
$yy = $y + $dy;
if ($xx >= 0 && $xx < $this->size && $yy >= 0 && $yy < $this->size) {
$distance = max(abs($dx), abs($dy));
$this->setFunction($xx, $yy, $distance !== 2 && $distance !== 4);
}
}
}
}
private function drawFormatBits(int $mask): void
{
$data = (self::FORMAT_BITS_M << 3) | $mask;
$remainder = $data;
for ($i = 0; $i < 10; $i++) {
$remainder = ($remainder << 1) ^ (($remainder >> 9) * 0x537);
}
$bits = (($data << 10) | $remainder) ^ 0x5412;
$bit = static fn (int $index): bool => (($bits >> $index) & 1) !== 0;
for ($i = 0; $i <= 5; $i++) {
$this->setFunction(8, $i, $bit($i));
}
$this->setFunction(8, 7, $bit(6));
$this->setFunction(8, 8, $bit(7));
$this->setFunction(7, 8, $bit(8));
for ($i = 9; $i < 15; $i++) {
$this->setFunction(14 - $i, 8, $bit($i));
}
for ($i = 0; $i < 8; $i++) {
$this->setFunction($this->size - 1 - $i, 8, $bit($i));
}
for ($i = 8; $i < 15; $i++) {
$this->setFunction(8, $this->size - 15 + $i, $bit($i));
}
$this->setFunction(8, $this->size - 8, true);
}
private function drawVersion(): void
{
if ($this->version < 7) {
return;
}
$remainder = $this->version;
for ($i = 0; $i < 12; $i++) {
$remainder = ($remainder << 1) ^ (($remainder >> 11) * 0x1F25);
}
$bits = ($this->version << 12) | $remainder;
for ($i = 0; $i < 18; $i++) {
$dark = (($bits >> $i) & 1) !== 0;
$a = $this->size - 11 + $i % 3;
$b = intdiv($i, 3);
$this->setFunction($a, $b, $dark);
$this->setFunction($b, $a, $dark);
}
}
/**
* @param list<int> $codewords
*/
private function drawCodewords(array $codewords): void
{
$totalBits = count($codewords) * 8;
$index = 0;
for ($right = $this->size - 1; $right >= 1; $right -= 2) {
if ($right === 6) {
$right = 5;
}
for ($vertical = 0; $vertical < $this->size; $vertical++) {
for ($j = 0; $j < 2; $j++) {
$x = $right - $j;
$upward = (($right + 1) & 2) === 0;
$y = $upward ? $this->size - 1 - $vertical : $vertical;
if (! $this->isFunction[$y][$x] && $index < $totalBits) {
$this->modules[$y][$x] = (($codewords[$index >> 3] >> (7 - ($index & 7))) & 1) !== 0;
$index++;
}
}
}
}
}
private function applyBestMask(): void
{
$bestMask = 0;
$bestPenalty = PHP_INT_MAX;
for ($mask = 0; $mask < 8; $mask++) {
$this->applyMask($mask);
$this->drawFormatBits($mask);
$penalty = $this->penaltyScore();
if ($penalty < $bestPenalty) {
$bestPenalty = $penalty;
$bestMask = $mask;
}
$this->applyMask($mask);
}
$this->applyMask($bestMask);
$this->drawFormatBits($bestMask);
}
private function applyMask(int $mask): void
{
for ($y = 0; $y < $this->size; $y++) {
for ($x = 0; $x < $this->size; $x++) {
if ($this->isFunction[$y][$x]) {
continue;
}
$invert = match ($mask) {
0 => ($x + $y) % 2 === 0,
1 => $y % 2 === 0,
2 => $x % 3 === 0,
3 => ($x + $y) % 3 === 0,
4 => (intdiv($x, 3) + intdiv($y, 2)) % 2 === 0,
5 => $x * $y % 2 + $x * $y % 3 === 0,
6 => ($x * $y % 2 + $x * $y % 3) % 2 === 0,
default => (($x + $y) % 2 + $x * $y % 3) % 2 === 0,
};
if ($invert) {
$this->modules[$y][$x] = ! $this->modules[$y][$x];
}
}
}
}
private function penaltyScore(): int
{
$size = $this->size;
$penalty = 0;
$dark = 0;
$lineScore = function (callable $cell) use ($size): int {
$score = 0;
for ($line = 0; $line < $size; $line++) {
$run = 1;
$sequence = '';
for ($i = 0; $i < $size; $i++) {
$current = $cell($line, $i);
$sequence .= $current ? '1' : '0';
if ($i > 0 && $current === $cell($line, $i - 1)) {
$run++;
} else {
if ($run >= 5) {
$score += $run - 2;
}
$run = 1;
}
}
if ($run >= 5) {
$score += $run - 2;
}
$padded = '0000' . $sequence . '0000';
$score += 40 * (substr_count($padded, '00001011101') + substr_count($padded, '10111010000'));
}
return $score;
};
$penalty += $lineScore(fn (int $line, int $i): bool => $this->modules[$line][$i]);
$penalty += $lineScore(fn (int $line, int $i): bool => $this->modules[$i][$line]);
for ($y = 0; $y < $size; $y++) {
for ($x = 0; $x < $size; $x++) {
if ($this->modules[$y][$x]) {
$dark++;
}
if ($x < $size - 1 && $y < $size - 1) {
$color = $this->modules[$y][$x];
if ($color === $this->modules[$y][$x + 1] && $color === $this->modules[$y + 1][$x] && $color === $this->modules[$y + 1][$x + 1]) {
$penalty += 3;
}
}
}
}
$total = $size * $size;
$k = intdiv(abs($dark * 20 - $total * 10) + $total - 1, $total) - 1;
return $penalty + max(0, $k) * 10;
}
/**
* @return list<int>
*/
private function alignmentPositions(): array
{
if ($this->version === 1) {
return [];
}
$count = intdiv($this->version, 7) + 2;
$step = $this->version === 32
? 26
: (int) ceil(($this->version * 4 + 4) / ($count * 2 - 2)) * 2;
$positions = [6];
for ($position = $this->size - 7; count($positions) < $count; $position -= $step) {
array_splice($positions, 1, 0, [$position]);
}
return $positions;
}
private function setFunction(int $x, int $y, bool $dark): void
{
$this->modules[$y][$x] = $dark;
$this->isFunction[$y][$x] = true;
}
private function numRawDataModules(int $version): int
{
$result = (16 * $version + 128) * $version + 64;
if ($version >= 2) {
$alignmentCount = intdiv($version, 7) + 2;
$result -= (25 * $alignmentCount - 10) * $alignmentCount - 55;
if ($version >= 7) {
$result -= 36;
}
}
return $result;
}
private function numDataCodewords(int $version): int
{
return intdiv($this->numRawDataModules($version), 8)
- self::ECC_CODEWORDS_PER_BLOCK[$version] * self::NUM_ERROR_CORRECTION_BLOCKS[$version];
}
/**
* @return list<int>
*/
private function reedSolomonDivisor(int $degree): array
{
$result = array_fill(0, $degree, 0);
$result[$degree - 1] = 1;
$root = 1;
for ($i = 0; $i < $degree; $i++) {
for ($j = 0; $j < $degree; $j++) {
$result[$j] = $this->gfMultiply($result[$j], $root);
if ($j + 1 < $degree) {
$result[$j] ^= $result[$j + 1];
}
}
$root = $this->gfMultiply($root, 0x02);
}
return $result;
}
/**
* @param list<int> $data
* @param list<int> $divisor
* @return list<int>
*/
private function reedSolomonRemainder(array $data, array $divisor): array
{
$result = array_fill(0, count($divisor), 0);
foreach ($data as $byte) {
$factor = $byte ^ array_shift($result);
$result[] = 0;
foreach ($divisor as $i => $coefficient) {
$result[$i] ^= $this->gfMultiply($coefficient, $factor);
}
}
return $result;
}
private function gfMultiply(int $x, int $y): int
{
$z = 0;
for ($i = 7; $i >= 0; $i--) {
$z = ($z << 1) ^ (($z >> 7) * 0x11D);
$z ^= (($y >> $i) & 1) * $x;
}
return $z & 0xFF;
}
}

View File

@ -2,6 +2,7 @@
namespace App\Support;
use App\Models\Business\CourierQuoteSignature;
use App\Models\Business\CourierQuoteVersion;
use Illuminate\Support\Facades\Schema;
use JsonException;
@ -17,24 +18,32 @@ final class QuoteManagementSnapshot
public function load(): array
{
$path = $this->path ?? storage_path(self::RELATIVE_PATH);
$versions = $this->versionQuotes();
$payload = null;
$failure = null;
if (! is_file($path)) {
return $this->emptySnapshot('missing', '尚未导入本机报价文件。');
$failure = $this->emptySnapshot('missing', '尚未导入本机报价文件。');
} else {
try {
$payload = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
$failure = $this->emptySnapshot('invalid', '本机报价快照无法读取,请重新导入源文件。');
}
if ($failure === null && (! is_array($payload) || ! is_array($payload['sheets'] ?? null))) {
$failure = $this->emptySnapshot('invalid', '本机报价快照结构不完整,请重新导入源文件。');
}
}
try {
$payload = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return $this->emptySnapshot('invalid', '本机报价快照无法读取,请重新导入源文件。');
}
if (! is_array($payload) || ! is_array($payload['sheets'] ?? null)) {
return $this->emptySnapshot('invalid', '本机报价快照结构不完整,请重新导入源文件。');
// 没有 Excel 快照时,系统内生成的报价版本(变价、快递入驻)照常显示。
if ($failure !== null && $versions === []) {
return $failure;
}
$quotes = [];
foreach (array_values($payload['sheets']) as $position => $sheet) {
foreach (array_values($failure === null ? $payload['sheets'] : []) as $position => $sheet) {
if (! is_array($sheet) || trim((string) ($sheet['name'] ?? '')) === '') {
continue;
}
@ -42,17 +51,13 @@ final class QuoteManagementSnapshot
$quotes[] = $this->normalizeSheet($sheet, $position);
}
if (Schema::hasTable('business_courier_quote_versions')) {
foreach (CourierQuoteVersion::query()->orderBy('id')->get() as $version) {
$quotes[] = $this->normalizeVersion($version);
}
}
array_push($quotes, ...$versions);
return [
'available' => true,
'state' => 'ready',
'message' => null,
'source_file' => basename(str_replace('\\', '/', (string) ($payload['sourcePath'] ?? '报价单合并.xlsx'))),
'source_file' => basename(str_replace('\\', '/', (string) (($payload['sourcePath'] ?? null) ?: '报价单合并.xlsx'))),
'quotes' => $quotes,
'summary' => [
'quote_count' => count($quotes),
@ -63,6 +68,44 @@ final class QuoteManagementSnapshot
];
}
/** @return list<array<string,mixed>> */
private function versionQuotes(): array
{
if (! Schema::hasTable('business_courier_quote_versions')) {
return [];
}
// 已入驻快递的版本,「变价」直接进后台变价页(不经过 …/create,避免被套餐限制中间件拦到用户页)。
$onboarded = Schema::hasTable('business_courier_onboardings')
? app(CourierRequoteService::class)->onboardedCourierIds()
: [];
$versions = CourierQuoteVersion::query()->with('signature')->orderBy('id')->get();
$superseded = $this->supersededVersions($versions);
$changeTypes = \App\Models\Business\CourierQuoteChange::query()
->whereIn('id', $versions->pluck('source_change_id')->filter()->unique()->all())
->pluck('change_type', 'id')
->all();
return $versions
->map(function (CourierQuoteVersion $version) use ($onboarded, $changeTypes, $superseded): array {
$quote = $this->normalizeVersion($version);
$quote['void_reason'] = $version->isVoided() ? $version->void_reason : null;
if (isset($superseded[$version->id])) {
$quote = [...$quote, ...$superseded[$version->id]];
}
$changeType = $changeTypes[$version->source_change_id] ?? null;
if (in_array($changeType, [CourierRequoteService::CHANGE_PRICE, CourierRequoteService::CHANGE_STRUCTURE], true)) {
$quote['quote_type'] = CourierRequoteService::quoteTypeFor($changeType);
}
$onboardingId = $onboarded[$version->company_name] ?? null;
$quote['requote_url'] = $onboardingId ? route('business.courier-onboardings.requote', $onboardingId) : null;
return $quote;
})
->all();
}
private function normalizeSheet(array $sheet, int $position): array
{
$name = trim((string) $sheet['name']);
@ -84,6 +127,8 @@ final class QuoteManagementSnapshot
'formula_count' => $this->formulaCount($formulas),
'status' => $this->extractEffectiveDate($values) === null ? '未标执行日期' : '已标执行日期',
'signature_status' => 'Excel 未提供',
'signed_files' => [],
'signed_at' => null,
'values' => $values,
'formulas' => $formulas,
];
@ -110,6 +155,8 @@ final class QuoteManagementSnapshot
'carrier' => $version->carrier_name,
'quote_type' => $version->quote_type,
'effective_date' => $version->effective_date?->toDateString(),
'end_date' => $version->end_date?->toDateString(),
'period_label' => $version->isTemporary() ? $version->periodLabel() : null,
'source_range' => $version->source_range ?? '',
'row_count' => count($values),
'column_count' => $columnCount,
@ -119,9 +166,35 @@ final class QuoteManagementSnapshot
'values' => $values,
'formulas' => $formulas,
'source_change_id' => $version->source_change_id,
'signed_files' => $this->signedFiles($version),
'signed_at' => $version->signature?->completed_at?->format('Y-m-d H:i')
?? $version->signature?->archived_at?->format('Y-m-d H:i'),
];
}
/**
* 已签署归档的回签单电子档下载地址(签署 PDF、签署完成证书、批量导出 zip)。
*
* @return array<string,string>
*/
private function signedFiles(CourierQuoteVersion $version): array
{
$signature = $version->signature;
if ($signature === null || $signature->status !== CourierQuoteSignature::STATUS_ARCHIVED || blank($signature->signed_pdf_path)) {
return [];
}
$files = [
'signed' => route('business.courier-quote-signatures.download', [$signature, 'signed']),
'export' => route('business.courier-quote-signatures.sheet', $version),
];
if (filled($signature->certificate_path)) {
$files['certificate'] = route('business.courier-quote-signatures.download', [$signature, 'certificate']);
}
return $files;
}
private function decodeMatrix(?string $json): array
{
if ($json === null || trim($json) === '') {
@ -137,11 +210,63 @@ final class QuoteManagementSnapshot
private function versionLifecycleStatus(CourierQuoteVersion $version): string
{
if ($version->signature_status !== '已签署并归档') {
if ($version->isVoided()) {
return '已作废';
}
if (! $this->isSigned($version)) {
return '待生效(待签章)';
}
return $version->effective_date?->isFuture() ? '待生效' : '生效中';
if ($version->effective_date?->isFuture()) {
return $version->isTemporary() ? '待生效(临时)' : '待生效';
}
return $version->isTemporary() ? '生效中(临时)' : '生效中';
}
/** 回签单已归档(以签署记录为准,版本上的文字状态可能滞后)。 */
private function isSigned(CourierQuoteVersion $version): bool
{
return $version->signature_status === '已签署并归档'
|| $version->signature?->status === \App\Models\Business\CourierQuoteSignature::STATUS_ARCHIVED;
}
/**
* 同一家快递有多个已签署版本时,今天执行的那一版是「生效中」;
* 短期变价期间,原来的长期报价显示「暂停中」,到期后自动恢复;过了结束日期的短期变价显示「已到期」;
* 其余更早的版本标为「已失效」并注明被哪一版替代。
*
* @param \Illuminate\Support\Collection<int,CourierQuoteVersion> $versions
* @return array<int,array{status:string,superseded_by?:string,status_note?:string}> 版本 id => 状态覆盖
*/
private function supersededVersions(\Illuminate\Support\Collection $versions): array
{
$result = [];
$today = today();
foreach ($versions->groupBy('company_name') as $group) {
$current = CourierRequoteService::resolveAt($group, $today);
$base = CourierRequoteService::resolveAt($group, $today, false);
$started = $group->filter(fn (CourierQuoteVersion $version): bool => ! $version->isVoided() && $this->isSigned($version)
&& $version->effective_date !== null && $version->effective_date->lte($today));
foreach ($started as $version) {
if ($current !== null && $version->id === $current->id) {
if ($version->isTemporary() && $base !== null) {
$result[$version->id] = ['status' => '生效中(临时)', 'status_note' => $version->end_date->copy()->addDay()->toDateString() . ' 起恢复 ' . $base->version_code];
}
continue;
}
if ($version->isTemporary() && $version->end_date->lt($today)) {
$result[$version->id] = ['status' => '已到期', 'status_note' => '临时价 ' . $version->periodLabel()];
} elseif ($base !== null && $version->id === $base->id && $current?->isTemporary()) {
$result[$version->id] = ['status' => '暂停中', 'status_note' => $current->version_code . ' 临时价执行中,' . $current->end_date->copy()->addDay()->toDateString() . ' 起恢复'];
} elseif ($current !== null) {
$result[$version->id] = ['status' => '已失效', 'superseded_by' => $current->version_code];
}
}
}
return $result;
}
private function normalizeMatrix(mixed $matrix): array

View File

@ -706,6 +706,7 @@ $navigation = [
'title' => '快递报价',
'children' => [
['slug' => 'quote-management', 'title' => '报价管理'],
['slug' => 'courier-onboarding', 'title' => '快递入驻'],
['slug' => 'courier-information', 'title' => '快递资料'],
['slug' => 'agreement-signing', 'title' => '协议签署'],
[

View File

@ -0,0 +1,10 @@
<?php
return [
// 二维码 / 链接里给快递方打开的站点地址。留空时用当前访问地址;
// 生产填公网地址(如 https://caiwu.jiansuntuzhi.cn:8442),保证内网生成的二维码手机也能打开。
'public_url' => env('COURIER_ONBOARDING_PUBLIC_URL'),
// 是否允许初审人自己做财务复审。默认不允许(两人把关);测试/预览环境可打开方便一个人走完流程。
'allow_same_reviewer' => (bool) env('COURIER_ONBOARDING_ALLOW_SAME_REVIEWER', false),
];

View File

@ -0,0 +1,127 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('business_courier_onboardings')) {
Schema::create('business_courier_onboardings', function (Blueprint $table): void {
$table->increments('id');
$table->unsignedInteger('company_id');
$table->string('number', 32);
$table->string('token', 64);
$table->string('status', 32)->default('invited');
$table->string('invite_label', 100)->nullable();
$table->timestamp('expires_at')->nullable();
// 快递方填写:主体与联系人(company 公司 / person 个人)
$table->string('entity_type', 16)->default('company');
$table->text('id_number')->nullable();
$table->string('company_full_name')->nullable();
$table->string('unified_social_credit_code', 32)->nullable();
$table->string('legal_representative', 100)->nullable();
$table->string('business_address')->nullable();
$table->string('carrier_brand', 50)->nullable();
$table->string('network_name', 100)->nullable();
$table->string('service_type', 32)->nullable();
$table->string('contact_name', 100)->nullable();
$table->string('contact_phone', 32)->nullable();
$table->string('finance_contact_name', 100)->nullable();
$table->string('finance_contact_phone', 32)->nullable();
$table->text('pickup_warehouses')->nullable();
$table->string('esign_operator_name', 100)->nullable();
$table->string('esign_account')->nullable();
// 快递方填写:结算与开票
$table->string('bank_account_name')->nullable();
$table->string('bank_name')->nullable();
$table->text('bank_account_number')->nullable();
$table->string('invoice_type', 32)->nullable();
$table->decimal('tax_rate', 6, 2)->nullable();
$table->string('settlement_cycle', 32)->nullable();
// 快递方填写:首版报价
$table->string('quote_mode', 32)->nullable();
$table->string('volumetric_rule', 32)->nullable();
$table->unsignedInteger('volumetric_divisor')->nullable();
$table->date('quote_effective_date')->nullable();
$table->longText('quote_structure')->nullable();
$table->longText('quote_rows')->nullable();
$table->decimal('min_weight', 8, 2)->nullable();
$table->string('weight_rounding', 16)->nullable();
$table->decimal('per_ticket_surcharge', 8, 2)->nullable();
$table->text('quote_note')->nullable();
$table->text('remark')->nullable();
// 内部审核与开通
$table->string('courier_name')->nullable();
$table->string('reconciler', 100)->nullable();
$table->text('return_reason')->nullable();
$table->timestamp('submitted_at')->nullable();
$table->unsignedInteger('first_reviewed_by')->nullable();
$table->timestamp('first_reviewed_at')->nullable();
$table->unsignedInteger('final_reviewed_by')->nullable();
$table->timestamp('final_reviewed_at')->nullable();
$table->unsignedInteger('quote_change_id')->nullable();
$table->unsignedInteger('quote_version_id')->nullable();
$table->unsignedInteger('activated_by')->nullable();
$table->timestamp('activated_at')->nullable();
$table->unsignedInteger('created_by')->nullable();
$table->unsignedInteger('updated_by')->nullable();
$table->timestamps();
$table->softDeletes();
$table->unique('token', 'courier_onboardings_token_unique');
$table->unique(['company_id', 'number'], 'courier_onboardings_company_number_unique');
$table->index(['company_id', 'status'], 'courier_onboardings_company_status_index');
$table->index(['company_id', 'courier_name'], 'courier_onboardings_company_courier_index');
});
}
if (! Schema::hasTable('business_courier_onboarding_files')) {
Schema::create('business_courier_onboarding_files', function (Blueprint $table): void {
$table->increments('id');
$table->unsignedInteger('company_id');
$table->unsignedInteger('onboarding_id');
$table->string('kind', 40);
$table->string('original_name');
$table->string('path', 1024);
$table->string('mime', 100)->nullable();
$table->unsignedBigInteger('size')->default(0);
$table->timestamps();
$table->softDeletes();
$table->index(['company_id', 'onboarding_id'], 'courier_onboarding_files_onboarding_index');
});
}
if (! Schema::hasTable('business_courier_onboarding_events')) {
Schema::create('business_courier_onboarding_events', function (Blueprint $table): void {
$table->increments('id');
$table->unsignedInteger('company_id');
$table->unsignedInteger('onboarding_id');
$table->string('event_type', 32);
$table->string('from_status', 32)->nullable();
$table->string('to_status', 32)->nullable();
$table->text('note')->nullable();
$table->string('actor_type', 16)->default('user');
$table->unsignedInteger('actor_id')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['company_id', 'onboarding_id'], 'courier_onboarding_events_onboarding_index');
});
}
}
public function down(): void
{
Schema::dropIfExists('business_courier_onboarding_events');
Schema::dropIfExists('business_courier_onboarding_files');
Schema::dropIfExists('business_courier_onboardings');
}
};

View File

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 快递入驻后的后台变价:变价单直接保存完整报价结构(计费方式、分档、各省价格、附加规则)
* 和审核通过后要写入新版本的报价矩阵,支持计费方式整体调整。
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('business_courier_quote_changes')) {
return;
}
Schema::table('business_courier_quote_changes', function (Blueprint $table): void {
if (! Schema::hasColumn('business_courier_quote_changes', 'quote_payload')) {
$table->longText('quote_payload')->nullable();
}
if (! Schema::hasColumn('business_courier_quote_changes', 'proposed_values_json')) {
$table->longText('proposed_values_json')->nullable();
}
});
}
public function down(): void
{
if (! Schema::hasTable('business_courier_quote_changes')) {
return;
}
Schema::table('business_courier_quote_changes', function (Blueprint $table): void {
foreach (['quote_payload', 'proposed_values_json'] as $column) {
if (Schema::hasColumn('business_courier_quote_changes', $column)) {
$table->dropColumn($column);
}
}
});
}
};

View File

@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 报价版本作废:发错的版本标记为 voided,记录原因、时间和操作人,不再参与生效判断。
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('business_courier_quote_versions')) {
return;
}
Schema::table('business_courier_quote_versions', function (Blueprint $table): void {
if (! Schema::hasColumn('business_courier_quote_versions', 'void_reason')) {
$table->text('void_reason')->nullable();
}
if (! Schema::hasColumn('business_courier_quote_versions', 'voided_at')) {
$table->timestamp('voided_at')->nullable();
}
if (! Schema::hasColumn('business_courier_quote_versions', 'voided_by')) {
$table->unsignedInteger('voided_by')->nullable();
}
});
}
public function down(): void
{
if (! Schema::hasTable('business_courier_quote_versions')) {
return;
}
Schema::table('business_courier_quote_versions', function (Blueprint $table): void {
foreach (['void_reason', 'voided_at', 'voided_by'] as $column) {
if (Schema::hasColumn('business_courier_quote_versions', $column)) {
$table->dropColumn($column);
}
}
});
}
};

View File

@ -0,0 +1,59 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 快递入驻打款账户分对公 / 对私:原 bank_* 三列作为对公账户,新增 private_* 三列为对私账户(账号加密)。
* 个人入驻只有对私账户,已填在 bank_* 的个人数据挪到 private_*。
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('business_courier_onboardings')) {
return;
}
Schema::table('business_courier_onboardings', function (Blueprint $table): void {
if (! Schema::hasColumn('business_courier_onboardings', 'private_account_name')) {
$table->string('private_account_name')->nullable();
}
if (! Schema::hasColumn('business_courier_onboardings', 'private_bank_name')) {
$table->string('private_bank_name')->nullable();
}
if (! Schema::hasColumn('business_courier_onboardings', 'private_account_number')) {
$table->text('private_account_number')->nullable();
}
});
DB::table('business_courier_onboardings')
->where('entity_type', 'person')
->whereNull('private_account_name')
->update([
'private_account_name' => DB::raw('bank_account_name'),
'private_bank_name' => DB::raw('bank_name'),
'private_account_number' => DB::raw('bank_account_number'),
'bank_account_name' => null,
'bank_name' => null,
'bank_account_number' => null,
]);
}
public function down(): void
{
if (! Schema::hasTable('business_courier_onboardings')) {
return;
}
Schema::table('business_courier_onboardings', function (Blueprint $table): void {
foreach (['private_account_name', 'private_bank_name', 'private_account_number'] as $column) {
if (Schema::hasColumn('business_courier_onboardings', $column)) {
$table->dropColumn($column);
}
}
});
}
};

View File

@ -0,0 +1,54 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 入驻开通后的「ERP 开通」信息:ERP 专员在钉钉「新建快递」流程里填的快递代码、ERP账户ID 等,
* 流程完结后同步回来(或手动补录),快递资料据此把入驻和工作平台老数据合成一行。
*/
return new class extends Migration
{
private const COLUMNS = ['workplatform_expcode', 'erp_account_id', 'erp_opened_at', 'erp_opened_source', 'dingtalk_instance_id'];
public function up(): void
{
if (! Schema::hasTable('business_courier_onboardings')) {
return;
}
Schema::table('business_courier_onboardings', function (Blueprint $table): void {
if (! Schema::hasColumn('business_courier_onboardings', 'workplatform_expcode')) {
$table->string('workplatform_expcode', 64)->nullable();
}
if (! Schema::hasColumn('business_courier_onboardings', 'erp_account_id')) {
$table->string('erp_account_id', 64)->nullable();
}
if (! Schema::hasColumn('business_courier_onboardings', 'erp_opened_at')) {
$table->timestamp('erp_opened_at')->nullable();
}
if (! Schema::hasColumn('business_courier_onboardings', 'erp_opened_source')) {
$table->string('erp_opened_source', 32)->nullable();
}
if (! Schema::hasColumn('business_courier_onboardings', 'dingtalk_instance_id')) {
$table->string('dingtalk_instance_id', 64)->nullable();
}
});
}
public function down(): void
{
if (! Schema::hasTable('business_courier_onboardings')) {
return;
}
Schema::table('business_courier_onboardings', function (Blueprint $table): void {
foreach (self::COLUMNS as $column) {
if (Schema::hasColumn('business_courier_onboardings', $column)) {
$table->dropColumn($column);
}
}
});
}
};

View File

@ -0,0 +1,79 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 快递资料在财务系统这一侧的维护数据(不回写工作平台):
* - business_courier_profiles:按快递公司代码保存「编辑」后的字段覆盖值;
* - business_courier_profile_events:快递资料操作日志(编辑、打款信息新增/变更);
* - business_courier_payment_accounts:打款信息新增/变更记录,推送收款账户证明/变更协议签署后生效。
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('business_courier_profiles')) {
Schema::create('business_courier_profiles', function (Blueprint $table): void {
$table->id();
$table->unsignedInteger('company_id');
$table->string('expcode', 64);
$table->string('courier_name')->nullable();
$table->text('overrides')->nullable();
$table->unsignedInteger('created_by')->nullable();
$table->unsignedInteger('updated_by')->nullable();
$table->timestamps();
$table->softDeletes();
$table->unique(['company_id', 'expcode']);
});
}
if (! Schema::hasTable('business_courier_profile_events')) {
Schema::create('business_courier_profile_events', function (Blueprint $table): void {
$table->id();
$table->unsignedInteger('company_id');
$table->string('expcode', 64);
$table->string('action', 32);
$table->string('summary', 500);
$table->text('changes')->nullable();
$table->unsignedInteger('created_by')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['company_id', 'expcode']);
});
}
if (! Schema::hasTable('business_courier_payment_accounts')) {
Schema::create('business_courier_payment_accounts', function (Blueprint $table): void {
$table->id();
$table->unsignedInteger('company_id');
$table->string('expcode', 64);
$table->string('courier_name')->nullable();
$table->string('action', 16);
$table->string('account_type', 16);
$table->string('account_name');
$table->string('bank_name');
$table->text('account_number');
$table->date('effective_date')->nullable();
$table->string('old_account_name')->nullable();
$table->text('old_account_number')->nullable();
$table->date('old_stop_date')->nullable();
$table->date('change_date')->nullable();
$table->string('note', 500)->nullable();
$table->unsignedInteger('created_by')->nullable();
$table->unsignedInteger('updated_by')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['company_id', 'expcode']);
});
}
}
public function down(): void
{
Schema::dropIfExists('business_courier_payment_accounts');
Schema::dropIfExists('business_courier_profile_events');
Schema::dropIfExists('business_courier_profiles');
}
};

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 短期(临时)变价:变价单和报价版本加「结束日期」。留空 = 长期;填了 = 只在开始~结束期间执行,
* 到期后自动回到原来的长期报价,不用再签一次。
*/
return new class extends Migration
{
private const TABLES = ['business_courier_quote_changes', 'business_courier_quote_versions'];
public function up(): void
{
foreach (self::TABLES as $table) {
if (Schema::hasTable($table) && ! Schema::hasColumn($table, 'end_date')) {
Schema::table($table, function (Blueprint $blueprint): void {
$blueprint->date('end_date')->nullable()->after('effective_date');
});
}
}
}
public function down(): void
{
foreach (self::TABLES as $table) {
if (Schema::hasTable($table) && Schema::hasColumn($table, 'end_date')) {
Schema::table($table, function (Blueprint $blueprint): void {
$blueprint->dropColumn('end_date');
});
}
}
}
};

View File

@ -230,6 +230,39 @@ class CourierInformationMirrorRepository
];
}
/**
* 快递总览用:工作平台全部快递 + 按快递代码归组的打款账户。镜像未就绪返回 null。
*
* @return array{express:list<object>,accounts:array<string,list<object>>}|null
*/
public function overviewSource(): ?array
{
if (! $this->available() || ! $this->ready()) {
return null;
}
$express = $this->connection->table($this->dataset()->targetTable())
->select(['Id', 'expcode', 'expname', 'status', 'linker', 'linkmobile', 'paymethod', 'is_delete', 'modified'])
->orderBy('Id')
->get()
->filter(static fn ($row): bool => trim((string) ($row->expname ?? '')) !== '' && (string) ($row->is_delete ?? '0') !== '1')
->values()
->all();
$accounts = [];
$accountDataset = $this->datasets->get(self::ACCOUNT_DATASET_KEY);
if ($this->mirror->ready($accountDataset)) {
foreach ($this->connection->table($accountDataset->targetTable())
->select(['expcode', 'expname', 'accountType', 'account', 'accountName', 'accountkhh', 'erpAccountId'])
->orderBy('Id')
->get() as $row) {
$accounts[(string) $row->expcode][] = $row;
}
}
return ['express' => $express, 'accounts' => $accounts];
}
/**
* 返回指定快递名称对应的电子签章经办人账号。
*

View File

@ -17,6 +17,7 @@
@php($agreementType = trim((string) request('agreement_type', '')))
@php($agreementScope = trim((string) request('agreement_scope', $agreementStatus === 'archived' ? 'archived' : 'active')))
@php($agreementScope = in_array($agreementScope, ['active', 'archived', 'all'], true) ? $agreementScope : 'active')
@php($agreementPaymentPrefill = \App\Business\CourierProfileData::agreementPrefill(request('payment_account')))
@php($agreementFiltersActive = $agreementSearch !== '' || $agreementStatus !== '' || $agreementType !== '')
<section class="agreement-workspace" aria-label="协议签署" data-agreement-signing>
@ -1575,6 +1576,12 @@
</header>
<div class="agreement-request-body">
@if ($agreementPaymentPrefill)
<div class="agreement-template-note" role="status" data-payment-prefill-note style="border-color:#E3C98F;background:#FFF8E8">
<span class="material-icons-outlined" aria-hidden="true">campaign</span>
<div><strong>来自快递资料 · {{ $agreementPaymentPrefill['action'] }}:{{ $agreementPaymentPrefill['courier_name'] }}({{ $agreementPaymentPrefill['expcode'] }})</strong><p>已按打款信息预填「{{ $agreementPaymentPrefill['document_type'] === 'account-change' ? '收款账户变更' : '收款账户证明' }}」。请补齐 E 签宝账号、签署人等变更资料后推送给快递签署,签署完成后快递资料里这条打款信息显示为「已生效」。</p></div>
</div>
@endif
<section class="agreement-form-section" aria-labelledby="agreement-base-heading">
<h3 class="agreement-section-heading" id="agreement-base-heading">基础信息</h3>
<div class="agreement-form-grid">
@ -3951,6 +3958,46 @@
syncEsignAccount();
refreshPreview();
// 快递资料「打款信息新增 / 变更」跳过来:预填快递、文件类型和账户信息,提示补齐后推送。
var paymentPrefill = @json($agreementPaymentPrefill);
if (paymentPrefill) {
showTab('create', { updateUrl: false });
var courierPartner = root.querySelector('[data-partner-type][value="courier"]');
if (courierPartner && !courierPartner.checked) {
courierPartner.checked = true;
courierPartner.dispatchEvent(new Event('change', { bubbles: true }));
}
if (courierSelect && paymentPrefill.courier_name) {
var prefillOption = Array.prototype.find.call(courierSelect.options, function (option) { return option.value === paymentPrefill.courier_name; });
if (!prefillOption) {
prefillOption = new Option(paymentPrefill.courier_name, paymentPrefill.courier_name);
courierSelect.appendChild(prefillOption);
courierOptions.push({ value: paymentPrefill.courier_name, account: paymentPrefill.esign_account || '' });
}
if (paymentPrefill.esign_account && !prefillOption.dataset.esignAccount) {
prefillOption.dataset.esignAccount = paymentPrefill.esign_account;
}
courierSelect.value = paymentPrefill.courier_name;
courierSelect.dispatchEvent(new Event('change', { bubbles: true }));
}
documentSelect.value = paymentPrefill.document_type;
documentSelect.dispatchEvent(new Event('change', { bubbles: true }));
var prefillSigner = form.querySelector('[name="counterparty_signer_type"][value="' + paymentPrefill.signer_type + '"]');
if (prefillSigner) {
prefillSigner.checked = true;
prefillSigner.dispatchEvent(new Event('change', { bubbles: true }));
}
Object.keys(paymentPrefill.fields || {}).forEach(function (name) {
var input = form.querySelector('[name="' + name + '"]');
if (input) {
input.value = paymentPrefill.fields[name];
input.dispatchEvent(new Event('input', { bubbles: true }));
}
});
refreshPreview();
if (announcer) announcer.textContent = '已按快递资料的打款信息预填协议,请补齐签署人信息后推送。';
}
if (initialUrl.searchParams.has('agreement_step')) {
updateUrl(initialView, true);
}

View File

@ -49,6 +49,21 @@
array_filter($filters, static fn ($value) => $value !== '' && $value !== null),
));
};
$displayRows = $courierDisplayRows ?? [];
$detail = $courierDetail ?? null;
$selectedCode = (string) ($detail['code'] ?? '');
$selectedRow = $detail['row'] ?? null;
$selectUrl = static function (string $code, array $extra = []) use ($filters): string {
$page = (int) request('page', 1);
return route('business.pages.show', array_merge(
['section' => 'courier-payables', 'page' => 'courier-information', 'courier_tab' => 'express'],
array_filter($filters, static fn ($value) => $value !== '' && $value !== null),
$page > 1 ? ['page' => $page] : [],
['selected' => $code],
$extra,
));
};
@endphp
<style>
@ -295,13 +310,41 @@
@media (max-width: 1000px) {
.courier-information-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.courier-information-table th.is-onboarding { background: #EEF3F8; color: #3C5A75; }
.courier-information-table td.is-onboarding { background: #F8FAFC; }
.courier-information-table tr.is-selectable { cursor: pointer; }
.courier-information-table tr.is-selected td { background: #F7EFDD !important; }
.courier-information-table tr.is-selected td:first-child { box-shadow: inset 3px 0 0 var(--vip-content-accent, #b9954f); }
.courier-information-table td.is-overridden { background: #FFF8E8; }
.courier-information-table tr.is-finance-row td { background: #FBFAF6; }
.courier-information-button[disabled] { opacity: .5; cursor: not-allowed; }
.courier-information-link { margin-left: 6px; color: var(--vip-content-accent-dark, #856632); text-decoration: underline; }
.courier-detail { margin-top: 20px; padding: 16px; border: 1px solid var(--vip-border, #d8d2c7); border-radius: 12px; background: #fff; scroll-margin-top: 80px; }
.courier-detail__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.courier-detail__title { margin: 0 0 4px; font-size: 1rem; font-weight: 600; }
.courier-detail__actions, .courier-detail__toolbar, .courier-detail__form-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.courier-detail__toolbar { margin-bottom: 12px; }
.courier-detail__tabs { display: flex; gap: 20px; margin: 4px 0 12px; border-bottom: 1px solid var(--vip-border, #d8d2c7); }
.courier-detail__tab { position: relative; min-height: 40px; padding: 0; border: 0; background: none; color: var(--vip-content-muted, #595959); font: inherit; cursor: pointer; }
.courier-detail__tab.is-active { color: var(--vip-content-accent-dark, #856632); }
.courier-detail__tab.is-active::after { position: absolute; right: 0; bottom: -1px; left: 0; height: 3px; background: var(--vip-content-accent, #b9954f); content: ''; }
.courier-detail__form { margin-bottom: 14px; padding: 14px; border: 1px solid #E3C98F; border-radius: 10px; background: #FFFCF4; }
.courier-detail__form h4 { margin: 0 0 12px; font-size: .875rem; font-weight: 600; }
.courier-detail__form-actions { margin-top: 12px; }
@media (max-width: 560px) { .courier-detail__head { flex-direction: column; } }
.courier-overview-sub { color: #8A8478; font-size: 12px; }
.courier-overview-tag { display: inline-block; padding: 1px 8px; border-radius: 6px; font-size: 12px; background: #F1EEE6; color: #6F6A60; white-space: nowrap; }
.courier-overview-tag.is-ok { background: #EEF6EA; color: #2F6B2A; }
.courier-overview-tag.is-warn { background: #FFF6E5; color: #935F07; }
.courier-overview-tag.is-bad { background: #FDEDEC; color: #A1281F; }
.courier-overview-tag.is-muted { background: #F1EEE6; color: #7A7468; }
@media (max-width: 560px) {
.courier-information-filters { grid-template-columns: 1fr; }
.courier-information-status { align-items: flex-start; flex-direction: column; }
}
</style>
<div class="courier-information-page">
<div class="courier-information-page" data-courier-information>
<div class="courier-information-status" role="status">
<div class="courier-information-status__main">
<span class="courier-information-status__dot {{ $statusClass }}" aria-hidden="true"></span>
@ -341,11 +384,11 @@
<form method="GET" class="courier-information-filters" aria-label="快递资料筛选">
<input type="hidden" name="courier_tab" value="express">
<div class="courier-information-field">
<label for="courier-expcode">快递代码</label>
<label for="courier-expcode">快递公司代码</label>
<input id="courier-expcode" name="expcode" type="text" value="{{ $filters['expcode'] ?? '' }}" placeholder="精确匹配">
</div>
<div class="courier-information-field">
<label for="courier-expname">快递名称</label>
<label for="courier-expname">快递公司名称</label>
<input id="courier-expname" name="expname" type="text" value="{{ $filters['expname'] ?? '' }}" placeholder="名称关键词">
</div>
<div class="courier-information-field">
@ -380,17 +423,23 @@
<button type="submit" class="courier-information-button is-primary"><span class="material-icons-outlined" aria-hidden="true">search</span>查询</button>
<a class="courier-information-button" href="{{ route('business.courier-payables.courier-information.export', array_filter($filters, static fn ($value) => $value !== '' && $value !== null)) }}"><span class="material-icons-outlined" aria-hidden="true">download</span>导出</a>
<a class="courier-information-button" href="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-information']) }}"><span class="material-icons-outlined" aria-hidden="true">refresh</span>重置</a>
@if ($selectedRow)
<a class="courier-information-button" href="{{ $selectUrl($selectedCode, ['edit' => 1]) }}#courier-detail" data-courier-edit-open><span class="material-icons-outlined" aria-hidden="true">edit</span>编辑</a>
@else
<button type="button" class="courier-information-button" disabled title="先在表格里点选一家快递,再编辑"><span class="material-icons-outlined" aria-hidden="true">edit</span>编辑</button>
@endif
</div>
</form>
@elseif (in_array($tab, ['networks', 'accounts'], true))
<form method="GET" class="courier-information-filters" aria-label="{{ $tab === 'networks' ? '网点' : '打款信息' }}筛选">
@else
<form method="GET" class="courier-information-filters" aria-label="{{ ['networks' => '网点', 'accounts' => '打款信息', 'logs' => '操作日志'][$tab] }}筛选">
<input type="hidden" name="courier_tab" value="{{ $tab }}">
<div class="courier-information-field">
<label for="courier-related-expcode">快递代码</label>
<label for="courier-related-expcode">快递公司代码</label>
<input id="courier-related-expcode" name="expcode" type="text" value="{{ $filters['expcode'] ?? '' }}" placeholder="留空显示全部">
</div>
<div class="courier-information-actions">
<button type="submit" class="courier-information-button is-primary"><span class="material-icons-outlined" aria-hidden="true">search</span>查询</button>
<a class="courier-information-button" href="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-information', 'courier_tab' => $tab]) }}"><span class="material-icons-outlined" aria-hidden="true">refresh</span>重置</a>
</div>
</form>
@endif
@ -407,25 +456,7 @@
</div>
@endif
@if ($tab === 'logs')
<div class="courier-information-table-panel">
<div class="courier-information-table-wrap">
<table class="courier-information-table" style="min-width: 760px" aria-label="快递资料操作日志">
<thead><tr><th>ID</th><th>快递代码</th><th>操作时间</th><th>创建时间</th><th>修改时间</th></tr></thead>
<tbody>
@forelse ($rows ?? [] as $row)
<tr><td class="is-code">{{ $row->Id ?? '-' }}</td><td class="is-code">{{ $row->expcode ?? '-' }}</td><td>{{ $row->doTime ?? '-' }}</td><td>{{ $datetime($row->createTime ?? null) }}</td><td>{{ $datetime($row->modifyTime ?? null) }}</td></tr>
@empty
<tr><td colspan="5" class="courier-information-empty">{{ $courierError ? '数据暂时不可用。' : '当前筛选条件下没有操作日志。' }}</td></tr>
@endforelse
</tbody>
</table>
</div>
@if ($rows && !$courierError)
<div class="courier-information-footer"><span>第 {{ $rows->currentPage() }} / {{ max($rows->lastPage(), 1) }} 页,本页 {{ $rows->count() }} 条,共 {{ number_format($rows->total()) }} 条</span><nav class="courier-information-pagination" aria-label="操作日志分页"><a class="courier-information-page-link {{ $rows->onFirstPage() ? 'is-disabled' : '' }}" href="{{ $rows->previousPageUrl() ?: '#' }}" aria-label="上一页"><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></a><a class="courier-information-page-link {{ $rows->hasMorePages() ? '' : 'is-disabled' }}" href="{{ $rows->nextPageUrl() ?: '#' }}" aria-label="下一页"><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></a></nav></div>
@endif
</div>
@elseif ($tab === 'express')
@if ($tab === 'express')
<div class="courier-information-table-panel">
<div class="courier-information-table-wrap">
<table class="courier-information-table" aria-label="快递资料">
@ -435,37 +466,64 @@
<th>是否启用</th><th>可达服务</th><th>是否快运</th><th>自动寻号</th><th>配货单号寻号</th><th>数量拆单</th><th>重量0寻号</th>
<th>停运时间</th><th>合同开始时间</th><th>合同结束时间</th><th>联系人</th><th>联系电话</th><th>自定义1</th><th>自定义2</th><th>自定义3</th>
<th>体积0寻号</th><th>手工单号</th><th>解密</th><th>电子签章经办人</th><th>最后修改</th>
<th class="is-onboarding">入驻单号</th><th class="is-onboarding">入驻状态</th><th class="is-onboarding">ERP 开通</th><th class="is-onboarding">入驻主体</th>
<th class="is-onboarding">证照</th><th class="is-onboarding">生效报价</th>
</tr>
</thead>
<tbody>
@forelse ($rows ?? [] as $row)
@php $enabled = $bool($row->status ?? null) === '是'; @endphp
<tr>
@forelse ($displayRows as $row)
@php
$code = (string) ($row->expcode ?? '');
$onlyOnboarding = ! empty($row->_onboarding_only);
$overridden = $row->_overridden ?? [];
$cell = static fn (string $field): string => in_array($field, $overridden, true) ? 'is-overridden' : '';
$enabled = $bool($row->status ?? null) === '是';
$isSelected = $code !== '' && strcasecmp($code, $selectedCode) === 0;
@endphp
<tr class="{{ $code !== '' ? 'is-selectable' : '' }} {{ $isSelected ? 'is-selected' : '' }} {{ $onlyOnboarding ? 'is-onboarding-only' : '' }}" @if ($code !== '') data-select-url="{{ $selectUrl($code) }}#courier-detail" @endif>
<td class="is-code">{{ $row->Id ?? '-' }}</td>
<td class="is-code">{{ $row->expcode ?? '-' }}</td>
<td class="is-clamped" title="{{ $row->expname ?? '' }}">{{ $row->expname ?? '-' }}</td>
<td class="is-code">
@if ($code !== '')
<a href="{{ $selectUrl($code) }}#courier-detail" title="点开看网点 / 打款信息 / 操作日志">{{ $code }}</a>
@else
<span class="courier-overview-sub" title="入驻时没填快递公司代码,到入驻详情的「ERP 开通」里补上">未填代码</span>
@endif
</td>
<td class="is-clamped {{ $cell('expname') }}" title="{{ $row->expname ?? '' }}"><strong>{{ $row->expname ?? '-' }}</strong></td>
<td class="is-code">{{ $row->outcode ?? '-' }}</td>
<td class="is-code">{{ $row->scexpcode ?? '-' }}</td>
<td>{{ $row->paymethod ?? '-' }}</td>
<td><span class="courier-information-badge {{ $enabled ? 'is-enabled' : 'is-disabled' }}">{{ $enabled ? '是' : '否' }}</span></td>
<td>{{ $bool($row->addvalidsgn ?? null) }}</td><td>{{ $bool($row->kuaiyunsgn ?? null) }}</td><td>{{ $bool($row->autonosgn ?? null) }}</td>
<td>{{ $bool($row->packnosgn ?? null) }}</td><td>{{ $bool($row->skucountsplitsgn ?? null) }}</td><td>{{ $bool($row->weight0sgn ?? null) }}</td>
<td>{{ $date($row->stopdate ?? null) }}</td><td>{{ $date($row->tradebtime ?? null) }}</td><td>{{ $date($row->tradeetime ?? null) }}</td>
<td>{{ $row->linker ?? '-' }}</td><td>{{ $row->linkmobile ?? '-' }}</td>
<td>{{ $row->undefined1 ?? '-' }}</td><td>{{ $row->undefined2 ?? '-' }}</td><td>{{ $row->undefined3 ?? '-' }}</td>
<td>{{ $bool($row->volume0sgn ?? null) }}</td><td>{{ $bool($row->handnosgn ?? null) }}</td><td>{{ $bool($row->decryptsgn ?? null) }}</td>
<td class="is-code">{{ $row->dzqzjbr ?? '-' }}</td>
<td class="{{ $cell('paymethod') }}">{{ $row->paymethod ?? '-' }}</td>
<td class="{{ $cell('status') }}"><span class="courier-information-badge {{ $enabled ? 'is-enabled' : 'is-disabled' }}">{{ $enabled ? '是' : '否' }}</span></td>
<td>{{ $onlyOnboarding ? '-' : $bool($row->addvalidsgn ?? null) }}</td>
<td class="{{ $cell('kuaiyunsgn') }}">{{ $bool($row->kuaiyunsgn ?? null) }}</td>
<td>{{ $onlyOnboarding ? '-' : $bool($row->autonosgn ?? null) }}</td>
<td>{{ $onlyOnboarding ? '-' : $bool($row->packnosgn ?? null) }}</td>
<td>{{ $onlyOnboarding ? '-' : $bool($row->skucountsplitsgn ?? null) }}</td>
<td>{{ $onlyOnboarding ? '-' : $bool($row->weight0sgn ?? null) }}</td>
<td class="{{ $cell('stopdate') }}">{{ $date($row->stopdate ?? null) }}</td>
<td class="{{ $cell('tradebtime') }}" @if ($onlyOnboarding) title="协议签署 · 合作合同(已签署)" @endif>{{ $date($row->tradebtime ?? null) }}</td>
<td class="{{ $cell('tradeetime') }}" @if ($onlyOnboarding) title="协议签署 · 合作合同(已签署)" @endif>{{ $date($row->tradeetime ?? null) }}</td>
<td class="{{ $cell('linker') }}">{{ $row->linker ?? '-' }}</td>
<td class="{{ $cell('linkmobile') }}">{{ $row->linkmobile ?? '-' }}</td>
<td class="{{ $cell('undefined1') }}">{{ $row->undefined1 ?? '-' }}</td>
<td class="{{ $cell('undefined2') }}">{{ $row->undefined2 ?? '-' }}</td>
<td class="{{ $cell('undefined3') }}">{{ $row->undefined3 ?? '-' }}</td>
<td>{{ $onlyOnboarding ? '-' : $bool($row->volume0sgn ?? null) }}</td>
<td>{{ $onlyOnboarding ? '-' : $bool($row->handnosgn ?? null) }}</td>
<td>{{ $onlyOnboarding ? '-' : $bool($row->decryptsgn ?? null) }}</td>
<td class="is-code {{ $cell('dzqzjbr') }}">{{ $row->dzqzjbr ?? '-' }}</td>
<td class="is-code">{{ $datetime($row->modified ?? null) }}</td>
@include('business.pages.courier-payables.partials.courier-onboarding-cells', ['info' => $row->_info ?? null, 'platformName' => $row->expname ?? null, 'platformCode' => $row->expcode ?? null])
</tr>
@empty
<tr><td colspan="26" class="courier-information-empty">{{ $courierError ? '数据暂时不可用。' : '当前筛选条件下没有快递资料。' }}</td></tr>
<tr><td colspan="32" class="courier-information-empty">{{ $courierError ? '数据暂时不可用。' : '当前筛选条件下没有快递资料。' }}</td></tr>
@endforelse
</tbody>
</table>
</div>
@if ($rows && !$courierError)
<div class="courier-information-footer">
<span>第 {{ $rows->currentPage() }} / {{ max($rows->lastPage(), 1) }} 页,本页 {{ $rows->count() }} 条,共 {{ number_format($rows->total()) }} 条</span>
<span>第 {{ $rows->currentPage() }} / {{ max($rows->lastPage(), 1) }} 页,本页 {{ $rows->count() }} 条,共 {{ number_format($rows->total()) }} 条 · 点一行快递在下方看网点 / 打款信息(含打款信息新增、变更)/ 操作日志;浅色底的格子是财务系统改过的值</span>
<nav class="courier-information-pagination" aria-label="快递资料分页">
<a class="courier-information-page-link {{ $rows->onFirstPage() ? 'is-disabled' : '' }}" href="{{ $rows->previousPageUrl() ?: '#' }}" aria-label="上一页"><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></a>
<a class="courier-information-page-link {{ $rows->hasMorePages() ? '' : 'is-disabled' }}" href="{{ $rows->nextPageUrl() ?: '#' }}" aria-label="下一页"><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></a>
@ -473,41 +531,135 @@
</div>
@endif
</div>
@if ($detail)
@include('business.pages.courier-payables.partials.courier-information-detail', ['detail' => $detail])
@endif
@elseif ($tab === 'networks')
<div class="courier-information-table-panel">
<div class="courier-information-table-wrap">
<table class="courier-information-table" style="min-width: 1560px" aria-label="快递网点">
<thead><tr><th>ID</th><th>快递代码</th><th>网点代码</th><th>网点名称</th><th>网点号</th><th>省</th><th>市</th><th>区</th><th>乡镇</th><th>地址</th><th>联系人</th><th>联系电话</th><th>是否启用</th><th>接口平台</th><th>创建时间</th><th>剩余单号</th></tr></thead>
<tbody>
@forelse ($rows ?? [] as $row)
<tr><td class="is-code">{{ $row->id ?? '-' }}</td><td class="is-code">{{ $row->expcode ?? '-' }}</td><td class="is-code">{{ $row->netcode ?? '-' }}</td><td>{{ $row->netname ?? '-' }}</td><td>{{ $row->netno ?? '-' }}</td><td>{{ $row->province ?? '-' }}</td><td>{{ $row->city ?? '-' }}</td><td>{{ $row->area ?? '-' }}</td><td>{{ $row->town ?? '-' }}</td><td class="is-clamped" title="{{ $row->address ?? '' }}">{{ $row->address ?? '-' }}</td><td>{{ $row->linker ?? '-' }}</td><td>{{ $row->linkermobile ?? '-' }}</td><td>{{ $bool($row->status ?? null) }}</td><td>{{ $row->apipltmname ?? ($row->apitype ?? '-') }}</td><td>{{ $datetime($row->created ?? null) }}</td><td>{{ $row->expnum ?? '-' }}</td></tr>
@empty
<tr><td colspan="16" class="courier-information-empty">{{ $courierError ? '数据暂时不可用。' : '当前筛选条件下没有网点资料。' }}</td></tr>
@endforelse
</tbody>
</table>
@include('business.pages.courier-payables.partials.courier-network-table', ['networkRows' => $rows ?? [], 'emptyText' => $courierError ? '数据暂时不可用。' : '当前筛选条件下没有网点资料。'])
</div>
@if ($rows && !$courierError)
<div class="courier-information-footer"><span>第 {{ $rows->currentPage() }} / {{ max($rows->lastPage(), 1) }} 页,本页 {{ $rows->count() }} 条,共 {{ number_format($rows->total()) }} 条</span><nav class="courier-information-pagination" aria-label="网点分页"><a class="courier-information-page-link {{ $rows->onFirstPage() ? 'is-disabled' : '' }}" href="{{ $rows->previousPageUrl() ?: '#' }}" aria-label="上一页"><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></a><a class="courier-information-page-link {{ $rows->hasMorePages() ? '' : 'is-disabled' }}" href="{{ $rows->nextPageUrl() ?: '#' }}" aria-label="下一页"><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></a></nav></div>
@endif
</div>
@elseif ($tab === 'accounts')
@php
$tabOldAccounts = collect($courierFinanceAccounts ?? [])
->map(static fn (array $account): array => ['type' => $account['type'], 'name' => $account['name'], 'number' => $account['number'], 'bank' => $account['bank'], 'source' => $account['source']])
->merge(($filters['expcode'] ?? '') !== '' && $rows ? collect($rows->items())->map(static fn ($account): array => ['type' => in_array($account->accountType ?? '', ['对公', '对私'], true) ? $account->accountType : '', 'name' => $account->accountName ?? '', 'number' => $account->account ?? '', 'bank' => $account->accountkhh ?? '', 'source' => '工作平台']) : [])
->filter(static fn (array $account): bool => filled($account['number']) && ($filters['expcode'] ?? '') !== '')
->unique('number')
->values();
@endphp
<div class="courier-detail" style="margin: 0 0 16px">
@if ($errors->any() && in_array((string) old('form'), ['add', 'change'], true))
<div class="courier-information-notice is-error" role="alert"><span class="material-icons-outlined" aria-hidden="true">error_outline</span><span>{{ $errors->first() }}</span></div>
@endif
@include('business.pages.courier-payables.partials.courier-payment-account-forms', ['code' => null, 'prefillCode' => $filters['expcode'] ?? '', 'oldAccounts' => $tabOldAccounts])
</div>
<div class="courier-information-table-panel">
<div class="courier-information-table-wrap">
@include('business.pages.courier-payables.partials.courier-account-table', ['financeAccounts' => $courierFinanceAccounts ?? [], 'platformAccounts' => $rows ?? [], 'showCourier' => true, 'rowActions' => true, 'emptyText' => '当前筛选条件下没有打款信息。'])
</div>
@if ($rows && !$courierError)
<div class="courier-information-footer"><span>第 {{ $rows->currentPage() }} / {{ max($rows->lastPage(), 1) }} 页 · 工作平台账户共 {{ number_format($rows->total()) }} 条;财务系统的打款信息(入驻对公/对私、新增/变更)显示在第一页最前面</span><nav class="courier-information-pagination" aria-label="打款信息分页"><a class="courier-information-page-link {{ $rows->onFirstPage() ? 'is-disabled' : '' }}" href="{{ $rows->previousPageUrl() ?: '#' }}" aria-label="上一页"><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></a><a class="courier-information-page-link {{ $rows->hasMorePages() ? '' : 'is-disabled' }}" href="{{ $rows->nextPageUrl() ?: '#' }}" aria-label="下一页"><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></a></nav></div>
@endif
</div>
@else
<div class="courier-information-table-panel">
<div class="courier-information-table-wrap">
<table class="courier-information-table" style="min-width: 1120px" aria-label="打款信息">
<thead><tr><th>ID</th><th>快递代码</th><th>快递名称</th><th>账户类型</th><th>账户</th><th>账户名称</th><th>开户行</th><th>创建时间</th><th>发票信息</th><th>ERP账户ID</th></tr></thead>
<tbody>
@forelse ($rows ?? [] as $row)
<tr><td class="is-code">{{ $row->Id ?? '-' }}</td><td class="is-code">{{ $row->expcode ?? '-' }}</td><td>{{ $row->expname ?? '-' }}</td><td>{{ $row->accountType ?? '-' }}</td><td class="is-code">{{ $row->account ?? '-' }}</td><td>{{ $row->accountName ?? '-' }}</td><td>{{ $row->accountkhh ?? '-' }}</td><td>{{ $datetime($row->createTime ?? null) }}</td><td>{{ $row->invoice ?? '-' }}</td><td class="is-code">{{ $row->erpAccountId ?? '-' }}</td></tr>
@empty
<tr><td colspan="10" class="courier-information-empty">{{ $courierError ? '数据暂时不可用。' : '当前筛选条件下没有打款账户。' }}</td></tr>
@endforelse
</tbody>
</table>
@include('business.pages.courier-payables.partials.courier-log-table', ['financeEvents' => $courierFinanceEvents ?? collect(), 'platformLogs' => $rows ?? [], 'showCourier' => true, 'emptyText' => '当前筛选条件下没有操作日志。'])
</div>
@if ($rows && !$courierError)
<div class="courier-information-footer"><span>第 {{ $rows->currentPage() }} / {{ max($rows->lastPage(), 1) }} 页,本页 {{ $rows->count() }} 条,共 {{ number_format($rows->total()) }} 条</span><nav class="courier-information-pagination" aria-label="打款信息分页"><a class="courier-information-page-link {{ $rows->onFirstPage() ? 'is-disabled' : '' }}" href="{{ $rows->previousPageUrl() ?: '#' }}" aria-label="上一页"><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></a><a class="courier-information-page-link {{ $rows->hasMorePages() ? '' : 'is-disabled' }}" href="{{ $rows->nextPageUrl() ?: '#' }}" aria-label="下一页"><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></a></nav></div>
<div class="courier-information-footer"><span>第 {{ $rows->currentPage() }} / {{ max($rows->lastPage(), 1) }} 页 · 工作平台日志共 {{ number_format($rows->total()) }} 条;财务系统的操作(编辑、打款信息新增/变更)显示在第一页最前面</span><nav class="courier-information-pagination" aria-label="操作日志分页"><a class="courier-information-page-link {{ $rows->onFirstPage() ? 'is-disabled' : '' }}" href="{{ $rows->previousPageUrl() ?: '#' }}" aria-label="上一页"><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></a><a class="courier-information-page-link {{ $rows->hasMorePages() ? '' : 'is-disabled' }}" href="{{ $rows->nextPageUrl() ?: '#' }}" aria-label="下一页"><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></a></nav></div>
@endif
</div>
@endif
</div>
<script>
(function () {
var page = document.querySelector('[data-courier-information]');
if (!page || page.dataset.ready === 'true') return;
page.dataset.ready = 'true';
// 点表格行(不是点链接/按钮时)= 点开这家快递的明细。
page.addEventListener('click', function (event) {
var row = event.target.closest('tr[data-select-url]');
if (!row || event.target.closest('a, button, input, select, label')) return;
window.location.href = row.dataset.selectUrl;
});
// 明细里的网点 / 打款信息 / 操作日志:局部切换。
page.querySelectorAll('[data-detail-tab]').forEach(function (button) {
button.addEventListener('click', function () {
var name = button.dataset.detailTab;
page.querySelectorAll('[data-detail-tab]').forEach(function (item) {
var active = item.dataset.detailTab === name;
item.classList.toggle('is-active', active);
item.setAttribute('aria-selected', active ? 'true' : 'false');
});
page.querySelectorAll('[data-detail-panel]').forEach(function (panel) {
panel.hidden = panel.dataset.detailPanel !== name;
});
try {
var url = new URL(window.location.href);
url.searchParams.set('detail_tab', name);
window.history.replaceState(null, '', url.toString());
} catch (error) {}
});
});
// 打款信息新增 / 变更表单开关。
page.querySelectorAll('[data-account-form-open]').forEach(function (button) {
button.addEventListener('click', function () {
var target = button.dataset.accountFormOpen;
page.querySelectorAll('[data-account-form]').forEach(function (form) {
form.hidden = form.dataset.accountForm !== target ? true : !form.hidden;
});
var opened = page.querySelector('[data-account-form="' + target + '"]');
if (opened && !opened.hidden) {
var first = opened.querySelector('select, input:not([type="hidden"])');
if (first) first.focus();
}
});
});
page.querySelectorAll('[data-account-form-close]').forEach(function (button) {
button.addEventListener('click', function () {
button.closest('[data-account-form]').hidden = true;
});
});
// 打款信息表里某个账户点「变更」:打开变更表单,带出快递公司代码和原账户。
page.querySelectorAll('[data-account-change-from]').forEach(function (button) {
button.addEventListener('click', function () {
var form = page.querySelector('[data-account-form="change"]');
if (!form) return;
page.querySelectorAll('[data-account-form]').forEach(function (item) { item.hidden = item !== form; });
form.hidden = false;
var code = form.querySelector('[data-account-expcode]');
if (code) code.value = button.dataset.code || '';
form.querySelector('[name="old_account_name"]').value = button.dataset.name || '';
form.querySelector('[name="old_account_number"]').value = button.dataset.number || '';
if (button.dataset.type) form.querySelector('[name="account_type"]').value = button.dataset.type;
form.scrollIntoView({ behavior: 'smooth', block: 'start' });
var focus = form.querySelector('[name="account_name"]');
if (focus) focus.focus({ preventScroll: true });
});
});
// 变更:选原账户后带出原开户名称、原账号、账户类型。
page.querySelectorAll('[data-old-account-select]').forEach(function (select) {
select.addEventListener('change', function () {
var option = select.options[select.selectedIndex];
var form = select.closest('form');
if (!option || !option.value) return;
form.querySelector('[name="old_account_name"]').value = option.dataset.name || '';
form.querySelector('[name="old_account_number"]').value = option.dataset.number || '';
if (option.dataset.type) form.querySelector('[name="account_type"]').value = option.dataset.type;
});
});
})();
</script>

View File

@ -0,0 +1,444 @@
@php
/** @var \App\Models\Business\CourierOnboarding $onboarding */
$status = $onboarding->status;
$expired = $onboarding->linkExpired() && in_array($status, ['invited', 'returned'], true);
$badge = match ($status) {
'submitted', 'first_approved', 'approved' => 'is-pending',
'returned' => 'is-warning',
'activated' => 'is-success',
'rejected', 'cancelled' => 'is-muted',
default => 'is-info',
};
$show = static fn ($value): string => filled($value) ? (string) $value : '—';
$num = static fn ($v): string => $v === null || $v === '' ? '—' : rtrim(rtrim(number_format((float) $v, 4, '.', ''), '0'), '.');
$quoteColumns = $onboarding->quoteColumns();
$quoteRows = $onboarding->quoteRowList();
$quoteSheet = $onboarding->files->where('kind', \App\Models\Business\CourierOnboardingFile::QUOTE_SHEET_KIND)->last();
$quoteUrl = route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management']);
@endphp
<x-layouts.admin>
<x-slot name="title">快递入驻 {{ $onboarding->number }}</x-slot>
<x-slot name="content">
<style>
.cod { --ink:var(--vip-content-ink,#171717); --muted:var(--vip-content-muted,#595959); --gold:var(--vip-content-accent-dark,#856632); --soft:var(--vip-content-accent-soft,#F7F0DE); --line:var(--vip-border,#D8D2C7); margin:24px 40px; color:var(--ink); font-family:"JetBrains Mono","Microsoft YaHei","PingFang SC",monospace; font-size:13px; }
.cod * { box-sizing:border-box; }
.cod__toolbar { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; margin-bottom:16px; }
.cod__back { display:inline-flex; align-items:center; gap:5px; color:var(--gold); font-size:12px; text-decoration:none; }
.cod__heading { display:flex; align-items:center; flex-wrap:wrap; gap:10px; margin:6px 0 0; font-size:18px; font-weight:700; }
.cod__heading small { color:var(--muted); font-size:12px; font-weight:500; font-variant-numeric:tabular-nums; }
.cod__badge { display:inline-flex; align-items:center; padding:3px 10px; border-radius:999px; font-size:12px; font-weight:700; }
.cod__badge.is-pending { background:#fff4df; color:#935f07; } .cod__badge.is-warning { background:#fde9d4; color:#8a4a06; } .cod__badge.is-success { background:#edf5e8; color:#53793e; } .cod__badge.is-muted { background:#f1efea; color:#595959; } .cod__badge.is-info { background:#e8f2f8; color:#006395; }
.cod__layout { display:grid; grid-template-columns:minmax(0,1fr) 360px; gap:16px; align-items:start; }
.cod__main, .cod__side { display:grid; gap:16px; min-width:0; }
.cod__side { position:sticky; top:16px; }
.cod__panel { overflow:hidden; border:1px solid var(--line); border-radius:8px; background:#fff; }
.cod__panel-head { display:flex; align-items:center; justify-content:space-between; gap:8px; padding:12px 16px; border-bottom:1px solid var(--line); }
.cod__panel-title { display:flex; align-items:center; gap:8px; margin:0; font-size:14px; font-weight:600; }
.cod__panel-title .material-icons-outlined { color:var(--gold); font-size:19px; }
.cod__panel-body { padding:16px; }
.cod__meta { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:14px 18px; margin:0; }
.cod__meta dt { color:var(--muted); font-size:11px; }
.cod__meta dd { margin:4px 0 0; font-size:12.5px; word-break:break-all; font-variant-numeric:tabular-nums; }
.cod__meta .is-wide { grid-column:span 3; }
.cod__alert { display:flex; align-items:flex-start; gap:8px; margin-bottom:16px; padding:10px 12px; border:1px solid #D9BD88; border-radius:8px; background:#FFF8E8; color:#704800; font-size:12px; line-height:1.6; }
.cod__alert.is-success { border-color:#b7cda8; background:#f1f7ec; color:#2f4a22; }
.cod__alert.is-error { border-color:#e3b0b0; background:#fdf1f1; color:#7a1010; }
.cod__alert .material-icons-outlined { flex:none; font-size:18px; }
.cod__alert ul { margin:4px 0 0; padding-left:18px; }
.cod__button { display:inline-flex; align-items:center; justify-content:center; gap:6px; height:34px; padding:0 14px; border:1px solid var(--line); border-radius:8px; background:#fff; color:var(--ink); font:inherit; font-weight:500; text-decoration:none; white-space:nowrap; cursor:pointer; }
.cod__button--primary { border-color:var(--gold); background:var(--gold); color:#fff; } .cod__button--primary:hover { background:#6A5129; }
.cod__button--danger { border-color:#B80000; color:#8A1515; }
.cod__button .material-icons-outlined { font-size:17px; }
.cod__button[disabled] { cursor:not-allowed; opacity:.55; }
.cod__stack { display:grid; gap:10px; }
.cod__row { display:flex; flex-wrap:wrap; gap:8px; }
.cod__field { display:grid; gap:5px; } .cod__field label { color:var(--muted); font-size:11px; font-weight:600; }
.cod__field input, .cod__field textarea { width:100%; min-height:34px; padding:6px 10px; border:1px solid var(--line); border-radius:8px; background:#fff; color:var(--ink); font:inherit; }
.cod__field textarea { min-height:64px; resize:vertical; }
.cod__field input:focus, .cod__field textarea:focus { border-color:var(--gold); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.18); }
.cod__help { color:var(--muted); font-size:11px; line-height:1.55; }
.cod__divider { height:1px; margin:4px 0; background:var(--line); }
.cod__qr { display:grid; justify-items:center; gap:10px; }
.cod__qr img { width:220px; height:220px; border:1px solid var(--line); border-radius:8px; image-rendering:pixelated; }
.cod__link { display:flex; gap:6px; width:100%; }
.cod__link input { flex:1; min-width:0; height:34px; padding:0 8px; border:1px solid var(--line); border-radius:8px; background:#fffdf8; color:var(--muted); font:inherit; font-size:11px; }
.cod table { width:100%; border-collapse:collapse; font-size:12.5px; }
.cod th { padding:9px 12px; border-bottom:1px solid var(--line); background:var(--soft); font-size:12px; font-weight:600; text-align:left; white-space:nowrap; }
.cod td { padding:8px 12px; border-bottom:1px solid #ECE8E0; }
.cod tbody tr:last-child td { border-bottom:0; }
.cod .is-number { text-align:right; font-variant-numeric:tabular-nums; }
.cod__files { display:grid; grid-template-columns:repeat(auto-fill,minmax(150px,1fr)); gap:10px; }
.cod__file { display:grid; gap:6px; padding:8px; border:1px solid var(--line); border-radius:8px; background:#fffdf8; color:var(--ink); text-decoration:none; }
.cod__file-thumb { display:grid; height:96px; place-items:center; overflow:hidden; border-radius:6px; background:#f1ede4; color:var(--gold); }
.cod__file-thumb img { width:100%; height:100%; object-fit:cover; }
.cod__file-kind { color:var(--gold); font-size:11px; font-weight:700; }
.cod__file-name { overflow:hidden; font-size:11.5px; text-overflow:ellipsis; white-space:nowrap; }
.cod__file-size { color:var(--muted); font-size:10.5px; }
.cod__timeline { display:grid; }
.cod__event { position:relative; display:grid; grid-template-columns:120px 1fr; gap:14px; padding:0 0 16px 22px; }
.cod__event::before { position:absolute; top:4px; bottom:-4px; left:6px; width:1px; background:var(--line); content:''; }
.cod__event::after { position:absolute; top:4px; left:2px; width:9px; height:9px; border:2px solid var(--gold); border-radius:50%; background:#fff; content:''; }
.cod__event:last-child::before { display:none; }
.cod__event-time { color:var(--muted); font-size:11px; font-variant-numeric:tabular-nums; }
.cod__event-title { font-weight:600; }
.cod__event-note { margin-top:3px; color:var(--muted); font-size:12px; white-space:pre-wrap; }
details.cod__more summary { color:var(--gold); cursor:pointer; font-size:12px; }
details.cod__more[open] summary { margin-bottom:10px; }
@media (max-width:1200px) { .cod { margin:16px; } .cod__layout { grid-template-columns:1fr; } .cod__side { position:static; } .cod__meta { grid-template-columns:repeat(2,minmax(0,1fr)); } .cod__meta .is-wide { grid-column:span 2; } }
</style>
<div class="cod">
<div class="cod__toolbar">
<div>
<a class="cod__back" href="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-onboarding']) }}"><span class="material-icons-outlined" aria-hidden="true" style="font-size:16px">arrow_back</span>返回快递入驻</a>
<h1 class="cod__heading">{{ $onboarding->displayName() }}<span class="cod__badge {{ $badge }}">{{ $expired ? '链接已过期' : $onboarding->statusLabel() }}</span><small>{{ $onboarding->number }}</small></h1>
</div>
</div>
@if (session('success'))
<div class="cod__alert is-success" role="status"><span class="material-icons-outlined" aria-hidden="true">check_circle</span><span>{{ session('success') }}</span></div>
@endif
@if ($errors->any())
<div class="cod__alert is-error" role="alert"><span class="material-icons-outlined" aria-hidden="true">error_outline</span><div>操作未完成<ul>@foreach ($errors->all() as $message)<li>{{ $message }}</li>@endforeach</ul></div></div>
@endif
@if ($status === 'returned' && $onboarding->return_reason)
<div class="cod__alert" role="status"><span class="material-icons-outlined" aria-hidden="true">assignment_return</span><span>已退回补件:{{ $onboarding->return_reason }}</span></div>
@endif
<div class="cod__layout">
<div class="cod__main">
@if (in_array($status, ['invited', 'returned'], true) && blank($onboarding->company_full_name))
<div class="cod__panel"><div class="cod__panel-body cod__help">快递方还没有填写资料。把右侧二维码发给对方,扫码后即可在手机上填写;填写过程中可以随时暂存。</div></div>
@endif
<section class="cod__panel" aria-labelledby="cod-company">
<div class="cod__panel-head"><h2 class="cod__panel-title" id="cod-company"><span class="material-icons-outlined" aria-hidden="true">{{ $onboarding->isPerson() ? 'person' : 'apartment' }}</span>入驻主体与联系人</h2><span class="cod__badge {{ $onboarding->isPerson() ? 'is-warning' : 'is-info' }}">{{ $onboarding->entityLabel() }}入驻</span></div>
<div class="cod__panel-body">
<dl class="cod__meta">
<div class="is-wide"><dt>{{ $onboarding->subjectLabel() }}</dt><dd>{{ $show($onboarding->company_full_name) }}</dd></div>
@if ($onboarding->isPerson())
<div><dt>身份证号</dt><dd>{{ $show($onboarding->maskedIdNumber()) }}</dd></div>
@else
<div><dt>统一社会信用代码</dt><dd>{{ $show($onboarding->unified_social_credit_code) }}</dd></div>
<div><dt>法定代表人</dt><dd>{{ $show($onboarding->legal_representative) }}</dd></div>
@endif
<div><dt>快递品牌 / 业务类型</dt><dd>{{ $show($onboarding->carrier_brand) }} · {{ $show($onboarding->service_type) }}</dd></div>
<div><dt>网点名称</dt><dd>{{ $show($onboarding->network_name) }}</dd></div>
<div class="is-wide" style="grid-column:span 2"><dt>经营地址</dt><dd>{{ $show($onboarding->business_address) }}</dd></div>
<div><dt>业务对接人</dt><dd>{{ $show($onboarding->contact_name) }} {{ $onboarding->contact_phone }}</dd></div>
<div><dt>财务对账人</dt><dd>{{ $show($onboarding->finance_contact_name) }} {{ $onboarding->finance_contact_phone }}</dd></div>
<div><dt>e签宝签署人</dt><dd>{{ $show($onboarding->esign_operator_name) }} {{ $onboarding->esign_account }}</dd></div>
<div><dt>提交时间</dt><dd>{{ $onboarding->submitted_at?->format('Y-m-d H:i') ?? '—' }}</dd></div>
<div class="is-wide"><dt>可揽收仓库 / 区域</dt><dd>{{ $show($onboarding->pickup_warehouses) }}</dd></div>
</dl>
</div>
</section>
<section class="cod__panel" aria-labelledby="cod-settle">
<div class="cod__panel-head"><h2 class="cod__panel-title" id="cod-settle"><span class="material-icons-outlined" aria-hidden="true">account_balance</span>结算与开票</h2></div>
<div class="cod__panel-body">
@php
$accountChecks = $onboarding->accountChecks();
$checkBadge = static fn (?bool $ok, string $okText, string $badText): string => $ok === null ? '' : '<span style="margin-left:6px;font-size:12px;color:' . ($ok ? '#2F6B2A' : '#A1281F') . '">' . e($ok ? '✓ ' . $okText : '✗ ' . $badText) . '</span>';
@endphp
<dl class="cod__meta">
<div><dt>对公户名</dt><dd>{{ $show($onboarding->bank_account_name) }}{!! $checkBadge($accountChecks['corporate'], '与营业执照公司全称一致', '与公司全称不一致') !!}</dd></div>
<div><dt>对公开户行</dt><dd>{{ $show($onboarding->bank_name) }}</dd></div>
<div><dt>对公账号</dt><dd>{{ $show($onboarding->bank_account_number) }}</dd></div>
<div><dt>对私收款人</dt><dd>{{ $show($onboarding->private_account_name) }}{!! $checkBadge($accountChecks['private'], $onboarding->isPerson() ? '与本人姓名一致' : '与法定代表人一致', $onboarding->isPerson() ? '与本人姓名不一致' : '与法定代表人不一致') !!}</dd></div>
<div><dt>对私开户行</dt><dd>{{ $show($onboarding->private_bank_name) }}</dd></div>
<div><dt>对私卡号</dt><dd>{{ $show($onboarding->private_account_number) }}</dd></div>
<div><dt>开票类型</dt><dd>{{ $show($onboarding->invoice_type) }}</dd></div>
<div><dt>税率</dt><dd>{{ $onboarding->tax_rate !== null ? $num($onboarding->tax_rate) . '%' : '—' }}</dd></div>
<div><dt>结算周期</dt><dd>{{ $show($onboarding->settlement_cycle) }}</dd></div>
</dl>
<p class="cod__help" style="margin:12px 0 0">核对依据:快递填写的营业执照公司全称 / 法定代表人{{ $onboarding->isPerson() ? '(个人入驻为本人姓名)' : '' }}。请对照下方营业执照、身份证和银行卡照片确认填写无误。</p>
</div>
</section>
<section class="cod__panel" aria-labelledby="cod-quote">
<div class="cod__panel-head">
<h2 class="cod__panel-title" id="cod-quote"><span class="material-icons-outlined" aria-hidden="true">request_quote</span>{{ $quoteVersions->count() > 1 ? "入驻首版报价" : "报价" }}</h2>
@if ($onboarding->quoteVersion)
<a class="cod__button" href="{{ $quoteUrl }}"><span class="material-icons-outlined" aria-hidden="true">open_in_new</span>报价管理 {{ $onboarding->quoteVersion->version_code }}</a>
@endif
</div>
<div class="cod__panel-body">
<dl class="cod__meta" style="margin-bottom:14px">
<div><dt>计费方式</dt><dd><strong>{{ $show($onboarding->quoteModeLabel()) }}</strong></dd></div>
<div><dt>执行日期</dt><dd>{{ $onboarding->quote_effective_date?->format('Y-m-d') ?? '—' }}</dd></div>
<div><dt>重量取整</dt><dd>{{ \App\Support\CourierQuoteStructure::ROUNDING[$onboarding->weight_rounding ?? 'none'] ?? '不取整' }}</dd></div>
<div class="is-wide"><dt>计费规则</dt><dd>@foreach ($onboarding->quoteRuleLines() as $rule)<div>{{ $rule }}</div>@endforeach</dd></div>
<div class="is-wide"><dt>报价备注</dt><dd>{{ $show($onboarding->quote_note) }}</dd></div>
</dl>
@if ($onboarding->quote_mode === 'other')
<div class="cod__alert" style="margin:0"><span class="material-icons-outlined" aria-hidden="true">upload_file</span><span>特殊计费,快递上传了报价表{{ $quoteSheet ? ':' . $quoteSheet->original_name : '' }}。复审通过后请在「报价管理」里按报价表录入首版报价,再发起报价确认。</span></div>
@if ($quoteSheet)<p style="margin:10px 0 0"><a class="cod__button" href="{{ route('business.courier-onboardings.file', [$onboarding, $quoteSheet]) }}?download=1"><span class="material-icons-outlined" aria-hidden="true">download</span>下载报价表</a></p>@endif
@elseif ($quoteRows === [])
<p class="cod__help">快递方还没有填写报价明细。</p>
@else
<div style="overflow-x:auto">
<table aria-label="报价明细">
<thead><tr><th>省份 / 区域</th>@foreach ($quoteColumns as $column)<th class="is-number">{{ $column['label'] }}</th>@endforeach</tr></thead>
<tbody>
@foreach ($quoteRows as $row)
<tr><td>{{ $row['region'] }}</td>@foreach (\App\Support\CourierQuoteStructure::rowValues($row, $quoteColumns) as $cell)<td class="is-number">{{ $num($cell) }}</td>@endforeach</tr>
@endforeach
</tbody>
</table>
</div>
<p class="cod__help" style="margin:10px 0 0">共 {{ count($quoteRows) }} 个地区。复审通过后这份报价会作为首版报价写进报价管理,并生成报价确认单推送 e签宝。</p>
@endif
</div>
</section>
<section class="cod__panel" aria-labelledby="cod-files">
<div class="cod__panel-head"><h2 class="cod__panel-title" id="cod-files"><span class="material-icons-outlined" aria-hidden="true">attach_file</span>资料附件</h2><span class="cod__help">{{ $onboarding->files->count() }} 个</span></div>
<div class="cod__panel-body">
@if ($onboarding->files->isEmpty())
<p class="cod__help">暂未上传附件。</p>
@else
<div class="cod__files">
@foreach ($onboarding->files as $file)
@php $fileUrl = route('business.courier-onboardings.file', [$onboarding, $file]); @endphp
<a class="cod__file" href="{{ $fileUrl }}" target="_blank" rel="noopener">
<span class="cod__file-thumb">
@if ($file->isImage())
<img src="{{ $fileUrl }}" alt="{{ \App\Models\Business\CourierOnboardingFile::labelFor($file->kind, $onboarding->entity_type) }}" loading="lazy">
@else
<span class="material-icons-outlined" aria-hidden="true" style="font-size:34px">description</span>
@endif
</span>
<span class="cod__file-kind">{{ \App\Models\Business\CourierOnboardingFile::labelFor($file->kind, $onboarding->entity_type) }}</span>
<span class="cod__file-name" title="{{ $file->original_name }}">{{ $file->original_name }}</span>
<span class="cod__file-size">{{ $file->sizeLabel() }} · {{ $file->created_at?->format('m-d H:i') }}</span>
</a>
@endforeach
</div>
@endif
</div>
</section>
<section class="cod__panel" aria-labelledby="cod-timeline">
<div class="cod__panel-head"><h2 class="cod__panel-title" id="cod-timeline"><span class="material-icons-outlined" aria-hidden="true">history</span>处理记录</h2></div>
<div class="cod__panel-body">
<div class="cod__timeline">
@foreach ($onboarding->events->sortByDesc('id') as $event)
<div class="cod__event">
<span class="cod__event-time">{{ $event->created_at?->format('Y-m-d H:i') }}</span>
<div><div class="cod__event-title">{{ $event->eventLabel() }} · {{ $event->actorLabel() }}</div>@if ($event->note)<div class="cod__event-note">{{ $event->note }}</div>@endif</div>
</div>
@endforeach
</div>
</div>
</section>
</div>
<aside class="cod__side">
@if (in_array($status, ['invited', 'returned'], true))
<section class="cod__panel" aria-labelledby="cod-qr">
<div class="cod__panel-head"><h2 class="cod__panel-title" id="cod-qr"><span class="material-icons-outlined" aria-hidden="true">qr_code_2</span>入驻二维码</h2><span class="cod__help">{{ $onboarding->expires_at ? '有效期至 ' . $onboarding->expires_at->format('Y-m-d') : '' }}</span></div>
<div class="cod__panel-body cod__qr">
@if ($expired)
<div class="cod__alert" style="margin:0;width:100%"><span class="material-icons-outlined" aria-hidden="true">schedule</span><span>链接已过期,延期后原二维码即可继续使用。</span></div>
@endif
<img src="{{ route('business.courier-onboardings.qr', $onboarding) }}" alt="快递入驻二维码" width="220" height="220">
<div class="cod__link">
<input type="text" value="{{ $formUrl }}" readonly aria-label="入驻链接" data-copy-source>
<button type="button" class="cod__button" data-copy-button><span class="material-icons-outlined" aria-hidden="true">content_copy</span>复制</button>
</div>
<div class="cod__row">
<a class="cod__button" href="{{ route('business.courier-onboardings.qr', [$onboarding, 'download' => 1]) }}"><span class="material-icons-outlined" aria-hidden="true">download</span>下载二维码</a>
<form method="POST" action="{{ route('business.courier-onboardings.extend', $onboarding) }}">@csrf<button type="submit" class="cod__button"><span class="material-icons-outlined" aria-hidden="true">update</span>延期 30 天</button></form>
</div>
<form method="POST" action="{{ route('business.courier-onboardings.cancel', $onboarding) }}" data-confirm="作废后原二维码将无法打开,确认作废?">@csrf<button type="submit" class="cod__button cod__button--danger"><span class="material-icons-outlined" aria-hidden="true">block</span>作废邀请</button></form>
</div>
</section>
@endif
@if ($status === 'submitted')
<section class="cod__panel" aria-labelledby="cod-first">
<div class="cod__panel-head"><h2 class="cod__panel-title" id="cod-first"><span class="material-icons-outlined" aria-hidden="true">fact_check</span>初审</h2></div>
<div class="cod__panel-body cod__stack">
<p class="cod__help" style="margin:0">核对营业执照、开户证明和盖章报价单是否与填写一致。</p>
<form method="POST" action="{{ route('business.courier-onboardings.first-approve', $onboarding) }}" class="cod__stack">
@csrf
<div class="cod__field"><label for="cod-first-note">初审意见(选填)</label><textarea id="cod-first-note" name="note" maxlength="1000"></textarea></div>
<button type="submit" class="cod__button cod__button--primary"><span class="material-icons-outlined" aria-hidden="true">done</span>初审通过</button>
</form>
@include('business.pages.courier-payables.partials.courier-onboarding-return', ['onboarding' => $onboarding])
</div>
</section>
@elseif ($status === 'first_approved')
<section class="cod__panel" aria-labelledby="cod-final">
<div class="cod__panel-head"><h2 class="cod__panel-title" id="cod-final"><span class="material-icons-outlined" aria-hidden="true">verified</span>财务复审</h2></div>
<div class="cod__panel-body cod__stack">
@if ($canFinalReview)
<form method="POST" action="{{ route('business.courier-onboardings.final-approve', $onboarding) }}" class="cod__stack">
@csrf
@if ($onboarding->courier_name)
<div class="cod__field">
<label>系统快递名称</label>
<div style="font-size:15px;font-weight:700">{{ $onboarding->courier_name }}</div>
<span class="cod__help">生成二维码时已定好;复审通过后锁定,首版报价和 e签宝报价确认单都用这个名称。如需更正,请先在下方「内部信息」修改。</span>
</div>
@else
<div class="cod__field">
<label for="cod-courier-name">系统快递名称 *</label>
<input id="cod-courier-name" name="courier_name" type="text" maxlength="100" required value="{{ old('courier_name') }}">
<span class="cod__help">必须与报价名、发货数据里的「快递名称」完全一致,复审后不可再改。</span>
</div>
@endif
<div class="cod__field"><label for="cod-reconciler">对账人</label><input id="cod-reconciler" name="reconciler" type="text" maxlength="100" value="{{ old('reconciler', $onboarding->reconciler) }}"></div>
<div class="cod__field"><label for="cod-final-note">复审意见(选填)</label><textarea id="cod-final-note" name="note" maxlength="1000"></textarea></div>
<button type="submit" class="cod__button cod__button--primary"><span class="material-icons-outlined" aria-hidden="true">verified</span>复审通过,生成首版报价</button>
</form>
@elseif ($isAdmin)
<div class="cod__alert" style="margin:0"><span class="material-icons-outlined" aria-hidden="true">info</span><span>这条申请的初审是你做的。为防止一人包办,复审必须换另一位管理员账号登录来做,复审按钮会出现在这里。</span></div>
@else
<p class="cod__help" style="margin:0">等待管理员(财务)复审。</p>
@endif
@include('business.pages.courier-payables.partials.courier-onboarding-return', ['onboarding' => $onboarding])
</div>
</section>
@elseif ($status === 'approved')
@php
$quoteSigned = $quoteSignature?->status === 'archived';
$canPush = $isAdmin && $esignReady && $onboarding->quoteVersion && (! $quoteSignature || $quoteSignature->canSend());
@endphp
<section class="cod__panel" aria-labelledby="cod-sign">
<div class="cod__panel-head"><h2 class="cod__panel-title" id="cod-sign"><span class="material-icons-outlined" aria-hidden="true">draw</span>报价确认 · 开通</h2></div>
<div class="cod__panel-body cod__stack">
<dl class="cod__meta" style="grid-template-columns:1fr 1fr">
<div><dt>报价确认单</dt><dd>{{ $onboarding->quoteVersion?->version_code ?? '—' }}</dd></div>
<div><dt>e签宝状态</dt><dd><strong>{{ $quoteSignature?->statusLabel() ?? '未推送' }}</strong></dd></div>
<div style="grid-column:span 2"><dt>签署方</dt><dd>{{ $show($onboarding->company_full_name) }}<br>{{ $show($onboarding->esign_operator_name) }} · {{ $show($onboarding->esign_account) }}</dd></div>
</dl>
@if ($quoteSignature?->last_error)
<div class="cod__alert is-error" style="margin:0"><span class="material-icons-outlined" aria-hidden="true">error_outline</span><span>{{ $quoteSignature->last_error }}</span></div>
@endif
@unless ($esignReady)
<p class="cod__help" style="margin:0">E 签宝尚未配置(本地预览环境正常现象),报价确认单无法推送;线下签署后填写说明即可开通。</p>
@endunless
<div class="cod__row">
@if ($canPush)
<form method="POST" action="{{ route('business.courier-onboardings.push-quote', $onboarding) }}">@csrf<button type="submit" class="cod__button cod__button--primary"><span class="material-icons-outlined" aria-hidden="true">send</span>{{ $quoteSignature ? '重新推送' : '推送到 e签宝' }}</button></form>
@endif
@if ($quoteSignature?->esign_sign_flow_id && ! $quoteSigned)
<form method="POST" action="{{ route('business.courier-onboardings.sync-quote', $onboarding) }}">@csrf<button type="submit" class="cod__button"><span class="material-icons-outlined" aria-hidden="true">sync</span>同步签署状态</button></form>
<form method="POST" action="{{ route('business.courier-onboardings.sign-url', $onboarding) }}">@csrf<button type="submit" class="cod__button"><span class="material-icons-outlined" aria-hidden="true">link</span>获取签署链接</button></form>
@endif
@if ($quoteSigned && $quoteSignature->signed_pdf_path)
<a class="cod__button" href="{{ route('business.courier-quote-signatures.download', [$quoteSignature, 'signed']) }}"><span class="material-icons-outlined" aria-hidden="true">task</span>回签单 PDF</a>
<a class="cod__button" href="{{ route('business.courier-quote-signatures.sheet', $onboarding->quoteVersion) }}"><span class="material-icons-outlined" aria-hidden="true">table_view</span>导出电子档</a>
@endif
@if ($quoteSignature?->source_pdf_path)
<a class="cod__button" href="{{ route('business.courier-quote-signatures.download', [$quoteSignature, 'source']) }}"><span class="material-icons-outlined" aria-hidden="true">picture_as_pdf</span>报价确认单 PDF</a>
@endif
</div>
@if (session('quote_sign_url'))
<div class="cod__stack" style="gap:8px;padding:12px;border:1px dashed var(--cod-line, #d9cfb8);border-radius:10px">
<strong style="font-size:13px">签署链接(签署人 {{ $show($onboarding->esign_operator_name) }} 用手机扫码或打开)</strong>
<div style="width:150px;height:150px">{!! \App\Support\QrCode::svg(session('quote_sign_url'), 4, 2) !!}</div>
<input type="text" readonly value="{{ session('quote_sign_url') }}" onclick="this.select()" style="width:100%;font-size:13px;padding:6px 8px;border:1px solid #ddd;border-radius:6px">
<p class="cod__help" style="margin:0">可以直接把链接或二维码发给快递。打开后需先用 {{ $show($onboarding->esign_account) }} 登录 / 实名{{ $onboarding->isPerson() ? '' : ',并以「' . $onboarding->company_full_name . '」企业身份签署' }}。</p>
</div>
@endif
@if ($quoteSignature?->esign_sign_flow_id && ! $quoteSigned && ! $onboarding->isPerson())
<p class="cod__help" style="margin:0">企业签署的任务在 e签宝里归属「{{ $show($onboarding->company_full_name) }}」这家企业:签署人会收到短信;在 e签宝 App 里要切换到这家企业才能看到待签任务。企业名称必须和营业执照完全一致、且已在 e签宝完成企业认证(或由签署人当场认证),否则无法签署。</p>
@endif
<div class="cod__divider"></div>
@if ($isAdmin)
<form method="POST" action="{{ route('business.courier-onboardings.activate', $onboarding) }}" class="cod__stack">
@csrf
@unless ($quoteSigned || $signedAgreement)
<div class="cod__field"><label for="cod-offline">线下签署说明</label><textarea id="cod-offline" name="offline_note" maxlength="1000" placeholder="报价确认单未在 e签宝签完时必填,如:已线下盖章,原件在财务处">{{ old('offline_note') }}</textarea></div>
@endunless
<button type="submit" class="cod__button cod__button--primary"><span class="material-icons-outlined" aria-hidden="true">how_to_reg</span>确认开通</button>
@if ($quoteSigned)<span class="cod__help">报价确认单已签署归档,可以直接开通。</span>@endif
</form>
@else
<p class="cod__help" style="margin:0">推送和开通需要管理员账号。</p>
@endif
</div>
</section>
@elseif ($status === 'activated')
<section class="cod__panel"><div class="cod__panel-body cod__stack">
<div class="cod__alert is-success" style="margin:0"><span class="material-icons-outlined" aria-hidden="true">how_to_reg</span><span>{{ $onboarding->activated_at?->format('Y-m-d H:i') }} 已开通,可在快递资料「入驻快递」查看。</span></div>
<a class="cod__button" href="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-information', 'courier_tab' => 'onboarded']) }}"><span class="material-icons-outlined" aria-hidden="true">open_in_new</span>快递资料 · 入驻快递</a>
<a class="cod__button" href="{{ $quoteUrl }}"><span class="material-icons-outlined" aria-hidden="true">price_change</span>报价管理 · 快递报价(变价)</a>
@if ($quoteSignature?->status === 'archived' && $quoteSignature->signed_pdf_path)
<div class="cod__row">
<a class="cod__button" href="{{ route('business.courier-quote-signatures.download', [$quoteSignature, 'signed']) }}"><span class="material-icons-outlined" aria-hidden="true">task</span>回签单 PDF</a>
<a class="cod__button" href="{{ route('business.courier-quote-signatures.sheet', $onboarding->quoteVersion) }}"><span class="material-icons-outlined" aria-hidden="true">table_view</span>导出电子档</a>
</div>
@endif
</div></section>
@endif
<section class="cod__panel" aria-labelledby="cod-internal">
<div class="cod__panel-head"><h2 class="cod__panel-title" id="cod-internal"><span class="material-icons-outlined" aria-hidden="true">badge</span>内部信息</h2></div>
<div class="cod__panel-body">
@if (in_array($status, ['activated', 'rejected', 'cancelled'], true) || $onboarding->quote_version_id)
<dl class="cod__meta" style="grid-template-columns:1fr 1fr">
<div><dt>系统快递名称</dt><dd>{{ $show($onboarding->courier_name) }}</dd></div>
<div><dt>对账人</dt><dd>{{ $show($onboarding->reconciler) }}</dd></div>
</dl>
@else
<form method="POST" action="{{ route('business.courier-onboardings.internal', $onboarding) }}" class="cod__stack">
@csrf
<div class="cod__field"><label for="cod-internal-name">系统快递名称</label><input id="cod-internal-name" name="courier_name" type="text" maxlength="100" value="{{ $onboarding->courier_name }}" placeholder="如:新北中通"></div>
<div class="cod__field"><label for="cod-internal-reconciler">对账人</label><input id="cod-internal-reconciler" name="reconciler" type="text" maxlength="100" value="{{ $onboarding->reconciler }}"></div>
<button type="submit" class="cod__button">保存</button>
</form>
@endif
</div>
</section>
@if ($status === 'activated')
<section class="cod__panel" aria-labelledby="cod-erp">
<div class="cod__panel-head"><h2 class="cod__panel-title" id="cod-erp"><span class="material-icons-outlined" aria-hidden="true">hub</span>ERP 开通</h2>
@if ($onboarding->erpPending())<span class="cod__pill" style="margin-left:auto;padding:2px 8px;border-radius:6px;font-size:12px;background:#FFF6E5;color:#935F07">待 ERP 开通</span>@else<span class="cod__pill" style="margin-left:auto;padding:2px 8px;border-radius:6px;font-size:12px;background:#EEF6EA;color:#2F6B2A">已开通</span>@endif
</div>
<div class="cod__panel-body cod__stack">
@if ($onboarding->erpPending())
<p class="cod__help" style="margin:0">下一步:ERP 专员在钉钉发起「新建快递」流程,开通发货系统账号。流程完结后,流程里填的快递代码、ERP账户ID 会自动补到这里和快递资料。</p>
@else
<dl class="cod__meta" style="grid-template-columns:1fr 1fr">
<div><dt>快递代码</dt><dd>{{ $onboarding->workplatform_expcode }}</dd></div>
<div><dt>ERP账户ID</dt><dd>{{ $onboarding->erp_account_id ?: '—' }}</dd></div>
<div><dt>开通时间</dt><dd>{{ $onboarding->erp_opened_at?->format('Y-m-d H:i') ?? '—' }}</dd></div>
<div><dt>来源</dt><dd>{{ $onboarding->erpSourceLabel() ?: '—' }}</dd></div>
</dl>
@endif
<details>
<summary class="cod__help" style="cursor:pointer">钉钉流程同步不到时手动补录</summary>
<form method="POST" action="{{ route('business.courier-onboardings.erp-opening', $onboarding) }}" class="cod__stack" style="margin-top:10px">
@csrf
<div class="cod__field"><label for="cod-erp-code">快递代码</label><input id="cod-erp-code" name="workplatform_expcode" type="text" maxlength="64" value="{{ $onboarding->workplatform_expcode }}" placeholder="ERP 开通后的快递代码"></div>
<div class="cod__field"><label for="cod-erp-account">ERP账户ID</label><input id="cod-erp-account" name="erp_account_id" type="text" maxlength="64" value="{{ $onboarding->erp_account_id }}"></div>
<button type="submit" class="cod__button">保存</button>
</form>
</details>
</div>
</section>
@endif
</aside>
</div>
</div>
<script>
document.querySelectorAll('.cod [data-copy-button]').forEach(function (button) {
button.addEventListener('click', function () {
var input = document.querySelector('.cod [data-copy-source]');
if (!input) return;
input.select();
var done = function () { button.lastChild.textContent = '已复制'; setTimeout(function () { button.lastChild.textContent = '复制'; }, 1600); };
if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(input.value).then(done); } else { document.execCommand('copy'); done(); }
});
});
document.querySelectorAll('.cod form[data-confirm]').forEach(function (form) {
form.addEventListener('submit', function (event) { if (!window.confirm(form.getAttribute('data-confirm'))) event.preventDefault(); });
});
</script>
</x-slot>
</x-layouts.admin>

View File

@ -0,0 +1,185 @@
@php
$ready = (bool) ($onboardingReady ?? false);
$rows = $onboardingRows ?? null;
$group = $onboardingGroup ?? 'pending';
$groups = $onboardingGroups ?? [];
$counts = $onboardingCounts ?? [];
$search = $onboardingSearch ?? '';
$groupUrl = static fn (string $key): string => route('business.pages.show', array_filter([
'section' => 'courier-payables',
'page' => 'courier-onboarding',
'group' => $key,
'search' => $search !== '' ? $search : null,
]));
$badge = static fn (string $status): string => match ($status) {
'submitted', 'first_approved', 'approved' => 'is-pending',
'returned' => 'is-warning',
'activated' => 'is-success',
'rejected', 'cancelled' => 'is-muted',
default => 'is-info',
};
@endphp
<section class="co-page" aria-label="快递入驻">
<style>
.co-page { --co-ink:var(--vip-content-ink,#171717); --co-muted:var(--vip-content-muted,#595959); --co-accent:var(--vip-content-accent-dark,#856632); --co-soft:var(--vip-content-accent-soft,#F7F0DE); --co-border:var(--vip-border,#D8D2C7); display:grid; gap:1rem; color:var(--co-ink); font-family:JetBrains Mono,"Microsoft YaHei","PingFang SC",monospace; font-size:.8125rem; }
.co-page *, .co-page *::before, .co-page *::after { box-sizing:border-box; }
.co-notice { display:flex; align-items:flex-start; gap:.6rem; padding:.75rem 1rem; border:1px solid #d7c39d; border-radius:.65rem; background:#fffaf0; color:#6a5129; line-height:1.55; }
.co-notice .material-icons-outlined { color:var(--co-accent); font-size:1.15rem; }
.co-notice.is-error { border-color:#e3b0b0; background:#fdf1f1; color:#7a1010; } .co-notice.is-error .material-icons-outlined { color:#b80000; }
.co-hero { display:grid; grid-template-columns:minmax(0,1.4fr) minmax(18rem,1fr); gap:1rem; }
.co-card { overflow:hidden; border:1px solid var(--co-border); border-radius:.75rem; background:#fff; }
.co-card__head { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:.5rem 1rem; padding:.85rem 1.15rem; border-bottom:1px solid var(--co-border); }
.co-card__title { display:flex; align-items:center; gap:.45rem; margin:0; font-size:.9rem; font-weight:700; } .co-card__title .material-icons-outlined { color:var(--co-accent); font-size:1.1rem; }
.co-card__hint { color:var(--co-muted); font-size:.72rem; }
.co-flow { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:0; margin:0; padding:1rem 1.15rem 1.15rem; list-style:none; counter-reset:step; }
.co-flow li { position:relative; display:grid; gap:.25rem; padding-right:.75rem; counter-increment:step; }
.co-flow li::before { display:grid; width:1.6rem; height:1.6rem; place-items:center; border-radius:50%; background:var(--co-soft); color:var(--co-accent); font-weight:700; font-size:.72rem; content:counter(step); }
.co-flow li:not(:last-child)::after { position:absolute; top:.8rem; left:2rem; right:.5rem; height:1px; background:var(--co-border); content:''; }
.co-flow strong { font-size:.78rem; } .co-flow span { color:var(--co-muted); font-size:.7rem; line-height:1.5; }
.co-invite { display:grid; gap:.7rem; padding:1rem 1.15rem 1.15rem; }
.co-field { display:grid; gap:.3rem; min-width:0; } .co-field label { color:var(--co-muted); font-size:.72rem; font-weight:600; }
.co-field input, .co-field select { width:100%; height:2.2rem; padding:0 .6rem; border:1px solid var(--co-border); border-radius:.45rem; background:#fff; color:var(--co-ink); font:inherit; }
.co-field input:focus, .co-field select:focus { border-color:var(--co-accent); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.18); }
.co-help { color:var(--co-muted); font-size:.68rem; line-height:1.5; } .co-error { color:#b80000; font-size:.72rem; }
.co-invite__row { display:grid; grid-template-columns:minmax(0,1fr) 7rem; gap:.6rem; }
.co-button { display:inline-flex; align-items:center; justify-content:center; gap:.35rem; height:2.2rem; padding:0 .8rem; border:1px solid var(--co-border); border-radius:.45rem; background:#fff; color:var(--co-ink); font:inherit; font-weight:600; text-decoration:none; white-space:nowrap; cursor:pointer; }
.co-button .material-icons-outlined { font-size:1rem; } .co-button--primary { border-color:var(--co-accent); background:var(--co-accent); color:#fff; } .co-button--primary:hover { background:#6a5129; }
.co-toolbar { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:.75rem; padding:.75rem 1.15rem; border-bottom:1px solid var(--co-border); background:#fffdf8; }
.co-tabs { display:flex; flex-wrap:wrap; gap:1.1rem; }
.co-tab { display:inline-flex; align-items:center; gap:.35rem; min-height:2.1rem; padding:0 .15rem; border-bottom:2px solid transparent; color:var(--co-muted); font-weight:600; text-decoration:none; }
.co-tab.is-active { border-color:var(--co-accent); color:var(--co-accent); }
.co-tab__count { min-width:1.4rem; padding:.05rem .4rem; border-radius:999px; background:#f1ede4; color:var(--co-muted); font-size:.68rem; text-align:center; font-variant-numeric:tabular-nums; }
.co-tab.is-active .co-tab__count { background:var(--co-soft); color:var(--co-accent); }
.co-search { display:flex; gap:.45rem; } .co-search input { width:16rem; height:2.2rem; padding:0 .6rem; border:1px solid var(--co-border); border-radius:.45rem; font:inherit; }
.co-table-wrap { overflow:auto; }
.co-table { width:100%; min-width:62rem; border-collapse:separate; border-spacing:0; font-size:.75rem; }
.co-table th, .co-table td { padding:.62rem .7rem; border-bottom:1px solid var(--co-border); text-align:left; vertical-align:middle; white-space:nowrap; }
.co-table th { background:var(--co-soft); font-weight:700; }
.co-table tbody tr:hover td { background:#fcfaf4; }
.co-table .is-code { font-variant-numeric:tabular-nums; }
.co-table .is-clamp { max-width:16rem; overflow:hidden; text-overflow:ellipsis; }
.co-table a { color:var(--co-accent); font-weight:600; text-decoration:none; }
.co-badge { display:inline-flex; align-items:center; padding:.15rem .5rem; border-radius:999px; font-size:.7rem; font-weight:700; }
.co-badge.is-pending { background:#fff4df; color:#935f07; } .co-badge.is-warning { background:#fde9d4; color:#8a4a06; } .co-badge.is-success { background:#edf5e8; color:#53793e; } .co-badge.is-muted { background:#f1efea; color:#595959; } .co-badge.is-info { background:#e8f2f8; color:#006395; }
.co-empty { padding:3rem 1rem; text-align:center; color:var(--co-muted); }
.co-footer { display:flex; align-items:center; justify-content:space-between; padding:.65rem 1.15rem; color:var(--co-muted); font-size:.72rem; }
.co-footer nav { display:flex; gap:.35rem; } .co-footer a { display:inline-grid; width:2rem; height:2rem; place-items:center; border:1px solid var(--co-border); border-radius:.4rem; color:var(--co-ink); } .co-footer a.is-disabled { pointer-events:none; opacity:.4; }
@media (max-width: 1100px) { .co-hero { grid-template-columns:1fr; } .co-flow { grid-template-columns:repeat(2,minmax(0,1fr)); row-gap:1rem; } .co-flow li::after { display:none; } }
</style>
@if (! $ready)
<div class="co-notice is-error" role="alert">
<span class="material-icons-outlined" aria-hidden="true">error_outline</span>
<span>快递入驻数据表尚未完成迁移,请联系管理员执行数据库迁移后再使用。</span>
</div>
@else
@if (session('success'))
<div class="co-notice" role="status"><span class="material-icons-outlined" aria-hidden="true">check_circle</span><span>{{ session('success') }}</span></div>
@endif
<div class="co-hero">
<div class="co-card">
<div class="co-card__head">
<h2 class="co-card__title"><span class="material-icons-outlined" aria-hidden="true">route</span>入驻流程</h2>
<span class="co-card__hint">快递方无需账号,扫码即可填写</span>
</div>
<ol class="co-flow">
<li><strong>生成二维码</strong><span>右侧填个备注,生成后发给快递方</span></li>
<li><strong>快递扫码填写</strong><span>主体、联系人、结算账户、报价、资质照片</span></li>
<li><strong>对接人初审</strong><span>资料不全可退回,快递用原码补件</span></li>
<li><strong>财务复审</strong><span>确认系统快递名称,首版报价进报价管理</span></li>
<li><strong>签署并开通</strong><span>协议签署后开通,进快递资料「入驻快递」</span></li>
</ol>
</div>
<form class="co-card" id="invite" method="POST" action="{{ route('business.courier-onboardings.store') }}">
@csrf
<div class="co-card__head">
<h2 class="co-card__title"><span class="material-icons-outlined" aria-hidden="true">qr_code_2</span>生成入驻二维码</h2>
</div>
<div class="co-invite">
<div class="co-field">
<label for="co-expcode">快递公司代码 *</label>
<input id="co-expcode" name="workplatform_expcode" type="text" maxlength="64" required value="{{ old('workplatform_expcode', request('expcode')) }}" placeholder="如:SQZTKY" autocomplete="off">
<span class="co-help">与快递资料「快递公司代码」一致,入驻信息按这个代码对齐到快递资料。</span>
@error('workplatform_expcode')<span class="co-error">{{ $message }}</span>@enderror
</div>
<div class="co-field">
<label for="co-courier-name">系统快递名称 *</label>
<input id="co-courier-name" name="courier_name" type="text" maxlength="100" required value="{{ old('courier_name', request('courier_name')) }}" placeholder="如:新北中通、小件宿迁圆通">
<span class="co-help">我方规定的名称,需与报价名、发货数据里的快递名称完全一致;快递方看不到。</span>
@error('courier_name')<span class="co-error">{{ $message }}</span>@enderror
</div>
<div class="co-invite__row">
<div class="co-field">
<label for="co-invite-label">备注(快递方也能看到)</label>
<input id="co-invite-label" name="invite_label" type="text" maxlength="100" value="{{ old('invite_label') }}" placeholder="如:新北中通 · 张经理">
</div>
<div class="co-field">
<label for="co-valid-days">有效天数</label>
<select id="co-valid-days" name="valid_days">
@foreach ([7, 15, 30, 60, 90] as $days)
<option value="{{ $days }}" @selected((int) old('valid_days', 30) === $days)>{{ $days }} 天</option>
@endforeach
</select>
</div>
</div>
<button type="submit" class="co-button co-button--primary"><span class="material-icons-outlined" aria-hidden="true">qr_code_2</span>生成二维码</button>
</div>
</form>
</div>
<div class="co-card">
<div class="co-toolbar">
<nav class="co-tabs" aria-label="入驻状态">
@foreach ($groups as $key => $config)
<a class="co-tab {{ $group === $key ? 'is-active' : '' }}" href="{{ $groupUrl($key) }}" @if ($group === $key) aria-current="page" @endif>
{{ $config['label'] }}<span class="co-tab__count">{{ number_format($counts[$key] ?? 0) }}</span>
</a>
@endforeach
</nav>
<form class="co-search" method="GET" role="search">
<input type="hidden" name="group" value="{{ $group }}">
<input type="search" name="search" value="{{ $search }}" placeholder="单号 / 公司 / 网点 / 对接人 / 电话" aria-label="搜索入驻申请">
<button type="submit" class="co-button"><span class="material-icons-outlined" aria-hidden="true">search</span>查询</button>
</form>
</div>
<div class="co-table-wrap">
<table class="co-table" aria-label="入驻申请">
<thead>
<tr><th>入驻单号</th><th>状态</th><th>备注</th><th>类型</th><th>公司 / 姓名</th><th>网点 / 品牌</th><th>对接人</th><th>系统快递名称</th><th>附件</th><th>提交时间</th><th>最近更新</th><th>操作</th></tr>
</thead>
<tbody>
@forelse ($rows ?? [] as $row)
<tr>
<td class="is-code"><a href="{{ route('business.courier-onboardings.show', $row) }}">{{ $row->number }}</a></td>
<td><span class="co-badge {{ $badge($row->status) }}">{{ $row->linkExpired() && in_array($row->status, ['invited', 'returned'], true) ? '链接已过期' : $row->statusLabel() }}</span></td>
<td class="is-clamp" title="{{ $row->invite_label }}">{{ $row->invite_label ?: '—' }}</td>
<td>{{ $row->company_full_name ? $row->entityLabel() : '—' }}</td>
<td class="is-clamp" title="{{ $row->company_full_name }}">{{ $row->company_full_name ?: '—' }}</td>
<td>{{ $row->network_name ?: '—' }}{{ $row->carrier_brand ? ' · ' . $row->carrier_brand : '' }}</td>
<td>{{ $row->contact_name ?: '—' }}{{ $row->contact_phone ? ' ' . $row->contact_phone : '' }}</td>
<td>{{ $row->courier_name ?: '—' }}</td>
<td class="is-code">{{ $row->files_count }}</td>
<td class="is-code">{{ $row->submitted_at?->format('Y-m-d H:i') ?? '—' }}</td>
<td class="is-code">{{ $row->updated_at?->format('Y-m-d H:i') ?? '—' }}</td>
<td><a href="{{ route('business.courier-onboardings.show', $row) }}">{{ in_array($row->status, ['submitted', 'first_approved', 'approved'], true) ? '去处理' : '查看' }}</a></td>
</tr>
@empty
<tr><td colspan="12" class="co-empty">{{ $search !== '' ? '没有符合条件的入驻申请。' : '这里还没有入驻申请,先在上方生成一个二维码发给快递方。' }}</td></tr>
@endforelse
</tbody>
</table>
</div>
@if ($rows && $rows->total() > 0)
<div class="co-footer">
<span>第 {{ $rows->currentPage() }} / {{ max($rows->lastPage(), 1) }} 页,共 {{ number_format($rows->total()) }} 条</span>
<nav aria-label="分页">
<a class="{{ $rows->onFirstPage() ? 'is-disabled' : '' }}" href="{{ $rows->previousPageUrl() ?: '#' }}" aria-label="上一页"><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></a>
<a class="{{ $rows->hasMorePages() ? '' : 'is-disabled' }}" href="{{ $rows->nextPageUrl() ?: '#' }}" aria-label="下一页"><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></a>
</nav>
</div>
@endif
</div>
@endif
</section>

View File

@ -0,0 +1,66 @@
{{-- 打款信息(完整显示):财务系统(入驻对公/对私、新增/变更)在前,工作平台账户在后。 --}}
@php
$showCourier = $showCourier ?? false;
$rowActions = $rowActions ?? false;
$colspan = ($showCourier ? 12 : 10) + ($rowActions ? 1 : 0);
$changeButton = static fn (?string $code, ?string $type, ?string $name, ?string $number): string => filled($code) && filled($number)
? '<button type="button" class="courier-information-link" style="border:0;background:none;padding:0;cursor:pointer" data-account-change-from data-code="' . e($code) . '" data-type="' . e(in_array($type, ['对公', '对私'], true) ? $type : '') . '" data-name="' . e($name) . '" data-number="' . e($number) . '">变更</button>'
: '<span class="courier-overview-sub" title="' . (filled($code) ? '没有账号' : '这家快递还没有快递公司代码,先到入驻详情「ERP 开通」里补上') . '">-</span>';
@endphp
<table class="courier-information-table" style="min-width: {{ $showCourier ? 1700 : 1460 }}px" aria-label="打款信息">
<thead>
<tr>
<th>来源</th>
@if ($showCourier)<th>快递公司代码</th><th>快递公司名称</th>@endif
<th>账户类型</th><th>开户名称</th><th>开户银行</th><th>银行账号</th><th>生效日期</th><th>原账户(变更前)</th><th>状态</th><th>ERP账户ID</th><th>创建时间</th>@if ($rowActions)<th>操作</th>@endif
</tr>
</thead>
<tbody>
@foreach ($financeAccounts as $account)
<tr class="is-finance-row">
<td>
@if (! empty($account['onboarding']))
<a href="{{ route('business.courier-onboardings.show', $account['onboarding']) }}">{{ $account['source'] }}</a>
@else
{{ $account['source'] }}
@endif
</td>
@if ($showCourier)<td class="is-code">{{ $account['expcode'] ?: '-' }}</td><td>{{ $account['courier_name'] ?: '-' }}</td>@endif
<td>{{ $account['type'] }}</td>
<td>{{ $account['name'] ?: '-' }}</td>
<td>{{ $account['bank'] ?: '-' }}</td>
<td class="is-code">{{ $account['number'] ?: '-' }}</td>
<td class="is-code">{{ $account['effective_date'] ?: '-' }}</td>
<td>{{ $account['old'] ?: '-' }}</td>
<td>
<span class="courier-overview-tag {{ $account['tone'] }}">{{ $account['status'] }}</span>
@if (($account['status'] ?? '') === '待推送协议' && ! empty($account['record']))
<a class="courier-information-link" href="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_view' => 'create', 'payment_account' => $account['record']->id]) }}">去推送</a>
@endif
</td>
<td class="is-code">{{ $account['erp_account_id'] ?: '-' }}</td>
<td class="is-code">{{ $account['created_at'] ? substr($account['created_at'], 0, 16) : '-' }}</td>
@if ($rowActions)<td>{!! $changeButton($account['expcode'] ?? null, $account['type'] ?? null, $account['name'] ?? null, $account['number'] ?? null) !!}</td>@endif
</tr>
@endforeach
@foreach ($platformAccounts as $row)
<tr>
<td>工作平台{{ filled($row->invoice ?? null) ? ' · 发票 ' . $row->invoice : '' }}</td>
@if ($showCourier)<td class="is-code">{{ $row->expcode ?? '-' }}</td><td>{{ $row->expname ?? '-' }}</td>@endif
<td>{{ $row->accountType ?? '-' }}</td>
<td>{{ $row->accountName ?? '-' }}</td>
<td>{{ $row->accountkhh ?? '-' }}</td>
<td class="is-code">{{ $row->account ?? '-' }}</td>
<td>-</td>
<td>-</td>
<td><span class="courier-overview-tag is-muted">工作平台登记</span></td>
<td class="is-code">{{ $row->erpAccountId ?? '-' }}</td>
<td class="is-code">{{ filled($row->createTime ?? null) ? substr((string) $row->createTime, 0, 16) : '-' }}</td>
@if ($rowActions)<td>{!! $changeButton($row->expcode ?? null, $row->accountType ?? null, $row->accountName ?? null, $row->account ?? null) !!}</td>@endif
</tr>
@endforeach
@if (count($financeAccounts) === 0 && count($platformAccounts) === 0)
<tr><td colspan="{{ $colspan }}" class="courier-information-empty">{{ $emptyText }}</td></tr>
@endif
</tbody>
</table>

View File

@ -0,0 +1,101 @@
{{-- 点开快递资料主信息后的明细:网点 / 打款信息 / 操作日志,和编辑、打款信息新增/变更。 --}}
@php
$row = $detail['row'];
$onb = $detail['onboarding'] ?? null;
$code = $detail['code'];
$formFailed = (string) old('form', '');
$editing = ($detail['edit'] ?? false) || $formFailed === 'edit';
$activeTab = in_array($formFailed, ['add', 'change'], true) ? 'accounts' : ($detail['detail_tab'] ?? 'accounts');
$value = static function (string $field) use ($row): string {
$current = old($field, $row->{$field} ?? '');
if (in_array($field, \App\Models\Business\CourierProfile::DATE_FIELDS, true)) {
return $current ? substr((string) $current, 0, 10) : '';
}
if (in_array($field, \App\Models\Business\CourierProfile::BOOLEAN_FIELDS, true)) {
return in_array(strtolower((string) $current), ['1', 'true', 'yes', 'y', '是'], true) ? '1' : '0';
}
return (string) $current;
};
$oldAccounts = collect($detail['finance_accounts'] ?? [])
->filter(static fn (array $account): bool => filled($account['number'] ?? null))
->map(static fn (array $account): array => ['type' => $account['type'], 'name' => $account['name'], 'number' => $account['number'], 'bank' => $account['bank'], 'source' => $account['source']])
->merge(collect($detail['platform_accounts'] ?? [])->map(static fn ($account): array => ['type' => in_array($account->accountType ?? '', ['对公', '对私'], true) ? $account->accountType : '', 'name' => $account->accountName ?? '', 'number' => $account->account ?? '', 'bank' => $account->accountkhh ?? '', 'source' => '工作平台']))
->unique('number')
->values();
$today = now()->toDateString();
@endphp
<section class="courier-detail" id="courier-detail" aria-label="快递明细">
@if ($row === null)
<div class="courier-information-notice" role="status"><span class="material-icons-outlined" aria-hidden="true">info</span><span>没有找到快递公司代码为「{{ $code }}」的快递资料。</span></div>
@else
<header class="courier-detail__head">
<div>
<h3 class="courier-detail__title">{{ $row->expname ?: '-' }} <span class="courier-overview-sub is-code">{{ $code }}</span></h3>
<p class="courier-overview-sub">
{{ ! empty($row->_onboarding_only) ? '财务系统入驻' : '工作平台 + 财务系统' }}
@if ($onb) · 入驻 <a href="{{ route('business.courier-onboardings.show', $onb) }}">{{ $onb->number }}</a>({{ $onb->entityLabel() }} · {{ $onb->company_full_name ?: '未填主体' }})@endif
</p>
</div>
<div class="courier-detail__actions">
<a class="courier-information-button" href="{{ route('business.pages.show', array_merge(request()->except(['selected', 'edit', 'detail_tab']), ['section' => 'courier-payables', 'page' => 'courier-information'])) }}"><span class="material-icons-outlined" aria-hidden="true">close</span>收起</a>
</div>
</header>
@if ($errors->any() && in_array($formFailed, ['edit', 'add', 'change'], true))
<div class="courier-information-notice is-error" role="alert"><span class="material-icons-outlined" aria-hidden="true">error_outline</span><span>{{ $errors->first() }}</span></div>
@endif
@if ($editing)
<form method="POST" action="{{ route('business.courier-profiles.update', $code) }}" class="courier-detail__form" aria-label="编辑快递资料">
@csrf
<input type="hidden" name="form" value="edit">
<h4>编辑快递资料 <span class="courier-overview-sub">财务系统维护,不回写工作平台;改动记到操作日志</span></h4>
<div class="courier-information-filters" style="margin: 0; border: 0; padding: 0">
@foreach (\App\Models\Business\CourierProfile::EDITABLE as $field => $label)
<div class="courier-information-field">
<label for="profile-{{ $field }}">{{ $label }}{{ $field === 'expname' ? ' *' : '' }}</label>
@if (in_array($field, \App\Models\Business\CourierProfile::BOOLEAN_FIELDS, true))
<select id="profile-{{ $field }}" name="{{ $field }}"><option value="1" @selected($value($field) === '1')>是</option><option value="0" @selected($value($field) === '0')>否</option></select>
@elseif (in_array($field, \App\Models\Business\CourierProfile::DATE_FIELDS, true))
<input id="profile-{{ $field }}" name="{{ $field }}" type="date" value="{{ $value($field) }}">
@else
<input id="profile-{{ $field }}" name="{{ $field }}" type="text" value="{{ $value($field) }}" maxlength="{{ $field === 'expname' ? 100 : 255 }}" @if ($field === 'expname') required @endif>
@endif
</div>
@endforeach
</div>
<div class="courier-detail__form-actions">
<button type="submit" class="courier-information-button is-primary"><span class="material-icons-outlined" aria-hidden="true">save</span>保存</button>
<a class="courier-information-button" href="{{ route('business.pages.show', array_merge(request()->except(['edit']), ['section' => 'courier-payables', 'page' => 'courier-information'])) }}#courier-detail">取消</a>
</div>
</form>
@endif
<div class="courier-detail__tabs" role="tablist" aria-label="快递明细分类">
@foreach (['networks' => ['网点', count($detail['networks'])], 'accounts' => ['打款信息', count($detail['finance_accounts']) + count($detail['platform_accounts'])], 'logs' => ['操作日志', count($detail['events']) + count($detail['platform_logs'])]] as $key => [$label, $count])
<button type="button" role="tab" class="courier-detail__tab {{ $activeTab === $key ? 'is-active' : '' }}" aria-selected="{{ $activeTab === $key ? 'true' : 'false' }}" data-detail-tab="{{ $key }}">{{ $label }} <span class="courier-overview-sub">{{ $count }}</span></button>
@endforeach
</div>
<div data-detail-panel="networks" @if ($activeTab !== 'networks') hidden @endif>
<div class="courier-information-table-panel"><div class="courier-information-table-wrap">
@include('business.pages.courier-payables.partials.courier-network-table', ['networkRows' => $detail['networks'], 'emptyText' => '工作平台没有这家快递的网点(或网点镜像未就绪)。'])
</div></div>
</div>
<div data-detail-panel="accounts" @if ($activeTab !== 'accounts') hidden @endif>
@include('business.pages.courier-payables.partials.courier-payment-account-forms', ['code' => $code, 'oldAccounts' => $oldAccounts])
<div class="courier-information-table-panel"><div class="courier-information-table-wrap">
@include('business.pages.courier-payables.partials.courier-account-table', ['financeAccounts' => $detail['finance_accounts'], 'platformAccounts' => $detail['platform_accounts'], 'showCourier' => false, 'emptyText' => '还没有这家快递的打款信息,可点「打款信息新增」。'])
</div></div>
</div>
<div data-detail-panel="logs" @if ($activeTab !== 'logs') hidden @endif>
<div class="courier-information-table-panel"><div class="courier-information-table-wrap">
@include('business.pages.courier-payables.partials.courier-log-table', ['financeEvents' => $detail['events'], 'platformLogs' => $detail['platform_logs'], 'showCourier' => false, 'emptyText' => '还没有这家快递的操作日志。'])
</div></div>
</div>
@endif
</section>

View File

@ -0,0 +1,35 @@
{{-- 操作日志:财务系统(编辑、打款信息新增/变更)在前,工作平台日志在后。 --}}
@php
$showCourier = $showCourier ?? false;
@endphp
<table class="courier-information-table" style="min-width: 1100px" aria-label="快递资料操作日志">
<thead><tr><th>来源</th>@if ($showCourier)<th>快递公司代码</th>@endif<th>操作</th><th>内容</th><th>操作人</th><th>操作时间</th></tr></thead>
<tbody>
@foreach ($financeEvents as $event)
@php
$details = collect((array) $event->changes)->map(static fn ($change): string => ($change['label'] ?? '') . ':' . ($change['old'] ?? '空') . ' → ' . ($change['new'] ?? '空'))->implode(';');
@endphp
<tr class="is-finance-row">
<td>财务系统</td>
@if ($showCourier)<td class="is-code">{{ $event->expcode }}</td>@endif
<td>{{ $event->actionLabel() }}</td>
<td class="is-clamped" style="max-width: 520px" title="{{ $details ?: $event->summary }}">{{ $event->summary }}@if ($details)<div class="courier-overview-sub">{{ $details }}</div>@endif</td>
<td>{{ $event->creator?->name ?? '-' }}</td>
<td class="is-code">{{ $event->created_at?->format('Y-m-d H:i') ?? '-' }}</td>
</tr>
@endforeach
@foreach ($platformLogs as $row)
<tr>
<td>工作平台</td>
@if ($showCourier)<td class="is-code">{{ $row->expcode ?? '-' }}</td>@endif
<td>-</td>
<td>{{ $row->doTime ?? '-' }}</td>
<td>-</td>
<td class="is-code">{{ filled($row->modifyTime ?? $row->createTime ?? null) ? substr((string) ($row->modifyTime ?? $row->createTime), 0, 16) : '-' }}</td>
</tr>
@endforeach
@if (count($financeEvents) === 0 && count($platformLogs) === 0)
<tr><td colspan="{{ $showCourier ? 6 : 5 }}" class="courier-information-empty">{{ $emptyText }}</td></tr>
@endif
</tbody>
</table>

View File

@ -0,0 +1,14 @@
{{-- 快递网点表(工作平台 wp_express_expNet)。 --}}
@php
$yes = static fn ($value): string => in_array(strtolower((string) $value), ['1', 'true', 'yes', 'y'], true) || $value === true ? '是' : '否';
@endphp
<table class="courier-information-table" style="min-width: 1560px" aria-label="快递网点">
<thead><tr><th>ID</th><th>快递公司代码</th><th>网点代码</th><th>网点名称</th><th>网点号</th><th>省</th><th>市</th><th>区</th><th>乡镇</th><th>地址</th><th>联系人</th><th>联系电话</th><th>是否启用</th><th>接口平台</th><th>创建时间</th><th>剩余单号</th></tr></thead>
<tbody>
@forelse ($networkRows as $row)
<tr><td class="is-code">{{ $row->id ?? '-' }}</td><td class="is-code">{{ $row->expcode ?? '-' }}</td><td class="is-code">{{ $row->netcode ?? '-' }}</td><td>{{ $row->netname ?? '-' }}</td><td>{{ $row->netno ?? '-' }}</td><td>{{ $row->province ?? '-' }}</td><td>{{ $row->city ?? '-' }}</td><td>{{ $row->area ?? '-' }}</td><td>{{ $row->town ?? '-' }}</td><td class="is-clamped" title="{{ $row->address ?? '' }}">{{ $row->address ?? '-' }}</td><td>{{ $row->linker ?? '-' }}</td><td>{{ $row->linkermobile ?? '-' }}</td><td>{{ $yes($row->status ?? null) }}</td><td>{{ $row->apipltmname ?? ($row->apitype ?? '-') }}</td><td class="is-code">{{ filled($row->created ?? null) ? substr((string) $row->created, 0, 16) : '-' }}</td><td>{{ $row->expnum ?? '-' }}</td></tr>
@empty
<tr><td colspan="16" class="courier-information-empty">{{ $emptyText }}</td></tr>
@endforelse
</tbody>
</table>

View File

@ -0,0 +1,22 @@
{{-- 快递资料表格右侧:财务系统入驻信息($info 为空则留空)。对公/对私收款信息在下方「打款信息」里完整显示。 --}}
@php
$onb = $info['onboarding'] ?? null;
@endphp
@if ($onb)
<td class="is-onboarding is-code"><a href="{{ route('business.courier-onboardings.show', $onb) }}">{{ $onb->number }}</a></td>
<td class="is-onboarding">{{ $onb->statusLabel() }}</td>
<td class="is-onboarding">
@if (($info['erp_status'] ?? '') === 'pending')
<span class="courier-overview-tag is-warn" title="等 ERP 专员在钉钉发起「新建快递」流程,完结后自动补齐">待 ERP 开通</span>
@elseif (($info['erp_status'] ?? '') === 'opened')
<span class="courier-overview-tag is-ok">已开通</span>
@else
<span class="courier-overview-sub">-</span>
@endif
</td>
<td class="is-onboarding is-clamped" title="{{ $onb->company_full_name }}">{{ $onb->entityLabel() }} · {{ $onb->company_full_name ?: '-' }}</td>
<td class="is-onboarding">@if ($info['missing_files'] ?? [])<span class="courier-overview-tag is-warn" title="{{ implode('、', $info['missing_files']) }}">缺 {{ count($info['missing_files']) }} 项</span>@else<span class="courier-overview-tag is-ok">齐全</span>@endif</td>
<td class="is-onboarding is-code">{{ $info['effective_version'] ?? '-' }}</td>
@else
<td class="is-onboarding" colspan="6"><span class="courier-overview-sub">未入驻</span>@if (! empty($platformName)) · <a href="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-onboarding', 'courier_name' => $platformName, 'expcode' => $platformCode ?? null]) }}#invite" title="给这家快递发入驻二维码,补齐主体、账户、证照">发码补资料</a>@endif</td>
@endif

View File

@ -0,0 +1,22 @@
<details class="cod__more">
<summary>退回补件 / 不予合作</summary>
<div class="cod__stack">
<form method="POST" action="{{ route('business.courier-onboardings.return', $onboarding) }}" class="cod__stack">
@csrf
<div class="cod__field">
<label for="cod-return-reason-{{ $onboarding->id }}">需要快递方补充或修改的内容</label>
<textarea id="cod-return-reason-{{ $onboarding->id }}" name="reason" maxlength="1000" required placeholder="如:营业执照照片不清晰,请重新拍;开户行请写到支行">{{ old('reason') }}</textarea>
<span class="cod__help">快递方用原二维码打开即可看到原因并修改。</span>
</div>
<button type="submit" class="cod__button"><span class="material-icons-outlined" aria-hidden="true">assignment_return</span>退回补件</button>
</form>
<form method="POST" action="{{ route('business.courier-onboardings.reject', $onboarding) }}" class="cod__stack" data-confirm="确认不予合作?该申请将关闭。">
@csrf
<div class="cod__field">
<label for="cod-reject-reason-{{ $onboarding->id }}">不予合作原因</label>
<input id="cod-reject-reason-{{ $onboarding->id }}" name="reason" type="text" maxlength="1000" required>
</div>
<button type="submit" class="cod__button cod__button--danger"><span class="material-icons-outlined" aria-hidden="true">block</span>不予合作</button>
</form>
</div>
</details>

View File

@ -0,0 +1,72 @@
{{-- 打款信息新增 / 变更:按钮 + 两张表单。$code 为空时(打款信息页签)表单里自己填快递公司代码。 --}}
@php
$code = $code ?? null;
$oldAccounts = collect($oldAccounts ?? []);
$prefillCode = $prefillCode ?? '';
$formFailed = (string) old('form', '');
$today = now()->toDateString();
$formAction = $code !== null
? route('business.courier-profiles.payment-accounts.store', $code)
: route('business.courier-profiles.payment-accounts.store-any');
@endphp
<div class="courier-detail__toolbar">
<button type="button" class="courier-information-button is-primary" data-account-form-open="add"><span class="material-icons-outlined" aria-hidden="true">add_card</span>打款信息新增</button>
<button type="button" class="courier-information-button" data-account-form-open="change"><span class="material-icons-outlined" aria-hidden="true">swap_horiz</span>打款信息变更</button>
<span class="courier-overview-sub">{{ $code === null ? '先填快递公司代码;也可以点表格里某个账户的「变更」直接带出原账户。' : '' }}保存后自动跳到协议签署,推送「收款账户证明 / 收款账户变更」给快递签署,签完即生效。</span>
</div>
<form method="POST" action="{{ $formAction }}" class="courier-detail__form" data-account-form="add" @if ($formFailed !== 'add') hidden @endif aria-label="打款信息新增">
@csrf
<input type="hidden" name="form" value="add">
<input type="hidden" name="action" value="add">
<h4>打款信息新增 <span class="courier-overview-sub">→ 收款账户证明</span></h4>
<div class="courier-information-filters" style="margin: 0; border: 0; padding: 0">
@if ($code === null)
<div class="courier-information-field"><label for="add-expcode">快递公司代码 *</label><input id="add-expcode" name="expcode" type="text" maxlength="64" required placeholder="如:SQZTKY" value="{{ $formFailed === 'add' ? old('expcode') : $prefillCode }}" data-account-expcode></div>
@endif
<div class="courier-information-field"><label for="add-type">账户类型 *</label><select id="add-type" name="account_type" required>@foreach (\App\Models\Business\CourierPaymentAccount::ACCOUNT_TYPES as $type)<option value="{{ $type }}" @selected(old('form') === 'add' && old('account_type') === $type)>{{ $type }}</option>@endforeach</select></div>
<div class="courier-information-field"><label for="add-name">开户名称(持卡人) *</label><input id="add-name" name="account_name" type="text" maxlength="255" required value="{{ $formFailed === 'add' ? old('account_name') : '' }}"></div>
<div class="courier-information-field"><label for="add-bank">开户银行 *</label><input id="add-bank" name="bank_name" type="text" maxlength="255" required placeholder="如:工商银行宿迁分行" value="{{ $formFailed === 'add' ? old('bank_name') : '' }}"></div>
<div class="courier-information-field"><label for="add-number">银行账号 *</label><input id="add-number" name="account_number" type="text" inputmode="numeric" maxlength="64" autocomplete="off" required value="{{ $formFailed === 'add' ? old('account_number') : '' }}"></div>
<div class="courier-information-field"><label for="add-date">生效日期 *</label><input id="add-date" name="effective_date" type="date" required value="{{ $formFailed === 'add' ? old('effective_date', $today) : $today }}"></div>
<div class="courier-information-field"><label for="add-note">备注</label><input id="add-note" name="note" type="text" maxlength="500" value="{{ $formFailed === 'add' ? old('note') : '' }}"></div>
</div>
<div class="courier-detail__form-actions">
<button type="submit" class="courier-information-button is-primary"><span class="material-icons-outlined" aria-hidden="true">send</span>保存并去推送协议</button>
<button type="button" class="courier-information-button" data-account-form-close>取消</button>
</div>
</form>
<form method="POST" action="{{ $formAction }}" class="courier-detail__form" data-account-form="change" @if ($formFailed !== 'change') hidden @endif aria-label="打款信息变更">
@csrf
<input type="hidden" name="form" value="change">
<input type="hidden" name="action" value="change">
<h4>打款信息变更 <span class="courier-overview-sub">→ 收款账户变更</span></h4>
<div class="courier-information-filters" style="margin: 0; border: 0; padding: 0">
@if ($code === null)
<div class="courier-information-field"><label for="change-expcode">快递公司代码 *</label><input id="change-expcode" name="expcode" type="text" maxlength="64" required placeholder="如:SQZTKY" value="{{ $formFailed === 'change' ? old('expcode') : $prefillCode }}" data-account-expcode></div>
@endif
<div class="courier-information-field"><label for="change-pick">选择原账户</label>
<select id="change-pick" data-old-account-select>
<option value="">{{ $oldAccounts->isEmpty() ? '没有登记的账户,手工填写' : '请选择要停用的账户' }}</option>
@foreach ($oldAccounts as $index => $account)
<option value="{{ $index + 1 }}" data-name="{{ $account['name'] }}" data-number="{{ $account['number'] }}" data-type="{{ $account['type'] }}">{{ $account['type'] ? $account['type'] . ' · ' : '' }}{{ $account['name'] }} {{ $account['number'] }}({{ $account['source'] }})</option>
@endforeach
</select>
</div>
<div class="courier-information-field"><label for="change-old-name">原开户名称 *</label><input id="change-old-name" name="old_account_name" type="text" maxlength="255" required value="{{ $formFailed === 'change' ? old('old_account_name') : '' }}"></div>
<div class="courier-information-field"><label for="change-old-number">原账号 *</label><input id="change-old-number" name="old_account_number" type="text" inputmode="numeric" maxlength="64" autocomplete="off" required value="{{ $formFailed === 'change' ? old('old_account_number') : '' }}"></div>
<div class="courier-information-field"><label for="change-old-stop">原账户停止使用日期 *</label><input id="change-old-stop" name="old_stop_date" type="date" required value="{{ $formFailed === 'change' ? old('old_stop_date', $today) : $today }}"></div>
<div class="courier-information-field"><label for="change-type">新账户类型 *</label><select id="change-type" name="account_type" required>@foreach (\App\Models\Business\CourierPaymentAccount::ACCOUNT_TYPES as $type)<option value="{{ $type }}" @selected(old('form') === 'change' && old('account_type') === $type)>{{ $type }}</option>@endforeach</select></div>
<div class="courier-information-field"><label for="change-name">新开户名称 *</label><input id="change-name" name="account_name" type="text" maxlength="255" required value="{{ $formFailed === 'change' ? old('account_name') : '' }}"></div>
<div class="courier-information-field"><label for="change-bank">新开户银行 *</label><input id="change-bank" name="bank_name" type="text" maxlength="255" required value="{{ $formFailed === 'change' ? old('bank_name') : '' }}"></div>
<div class="courier-information-field"><label for="change-number">新银行账号 *</label><input id="change-number" name="account_number" type="text" inputmode="numeric" maxlength="64" autocomplete="off" required value="{{ $formFailed === 'change' ? old('account_number') : '' }}"></div>
<div class="courier-information-field"><label for="change-effective">新账户启用日期 *</label><input id="change-effective" name="effective_date" type="date" required value="{{ $formFailed === 'change' ? old('effective_date', $today) : $today }}"></div>
<div class="courier-information-field"><label for="change-date">账户变更日期 *</label><input id="change-date" name="change_date" type="date" required value="{{ $formFailed === 'change' ? old('change_date', $today) : $today }}"></div>
<div class="courier-information-field"><label for="change-note">备注</label><input id="change-note" name="note" type="text" maxlength="500" value="{{ $formFailed === 'change' ? old('note') : '' }}"></div>
</div>
<div class="courier-detail__form-actions">
<button type="submit" class="courier-information-button is-primary"><span class="material-icons-outlined" aria-hidden="true">send</span>保存并去推送协议</button>
<button type="button" class="courier-information-button" data-account-form-close>取消</button>
</div>
</form>

View File

@ -62,14 +62,65 @@
<div class="quote-review__toolbar"><a class="quote-review__back" href="{{ route('business.courier-quote-changes.index') }}"><span class="material-icons-outlined" aria-hidden="true" style="font-size:16px">arrow_back</span><span>返回报价历史</span></a><div class="quote-review__actions">@if($change->isEditable() && $change->created_by===user_id())<a class="quote-review__button" href="{{ route('business.courier-quote-changes.edit',$change) }}"><span class="material-icons-outlined" aria-hidden="true">edit</span><span>编辑草稿</span></a><form method="POST" action="{{ route('business.courier-quote-changes.submit',$change) }}">@csrf<button class="quote-review__button quote-review__button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">send</span><span>提交审核</span></button></form>@endif @if($canReview && $change->status==='pending_review')<form method="POST" action="{{ route('business.courier-quote-changes.approve',$change) }}" onsubmit="return confirm('审核通过后将生成不可覆盖的新报价版本,确认继续吗?')">@csrf<button class="quote-review__button quote-review__button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">verified</span><span>审核通过</span></button></form>@endif</div></div>
@if(is_string(session('success')))<div style="margin-bottom:16px;border:1px solid #BAD0AB;border-radius:8px;background:#F3F8EF;padding:10px 12px;color:#40622F" role="status">{{ session('success') }}</div>@endif
@if($errors->any())<div style="margin-bottom:16px;border:1px solid #E6B7B7;border-radius:8px;background:#FFF4F4;padding:10px 12px;color:#8A1515" role="alert">{{ $errors->first() }}</div>@endif
@if($reviewWarnings)<div class="quote-review__alert" role="alert" style="border-color:#E6B7B7;background:#FFF4F4;color:#8A1515"><span class="material-icons-outlined" aria-hidden="true">report</span><div><strong>审核提醒(请重点核对)</strong>@foreach($reviewWarnings as $warning)<br>· {{ $warning }}@endforeach</div></div>@endif
@if($change->isRetrospective())<div class="quote-review__alert" role="status"><span class="material-icons-outlined" aria-hidden="true">history</span><div><strong>追溯变价 · {{ $change->recalculationStatusLabel() }}</strong><br>自 {{ $change->effective_date->toDateString() }} 起生效。{{ $change->recalculation_note ?: '审核完成后应生成补款或扣款明细,原运费和原始 Excel 保持不变。' }}</div></div>@endif
<section class="quote-review__panel" aria-labelledby="review-base-title"><header class="quote-review__header"><div class="quote-review__title" id="review-base-title"><span class="material-icons-outlined" aria-hidden="true">assignment</span><span>{{ $change->number }} · {{ $change->statusLabel() }}</span></div></header><div class="quote-review__body"><dl class="quote-review__meta"><div><dt>原报价版本</dt><dd>{{ $change->source_quote_id }}</dd></div><div><dt>快递公司</dt><dd>{{ $change->carrier_name }}</dd></div><div><dt>报价类型</dt><dd>{{ $change->quote_type }}</dd></div><div><dt>变更类型</dt><dd>{{ $change->change_type }}</dd></div><div><dt>生效日期</dt><dd>{{ $change->effective_date->toDateString() }}</dd></div><div><dt>生效方式</dt><dd>{{ $change->isRetrospective() ? '追溯变价' : '正常生效' }}</dd></div><div><dt>追溯核算</dt><dd>{{ $change->recalculationStatusLabel() }}</dd></div><div><dt>变价明细</dt><dd>{{ $change->items->count() }} 项</dd></div><div><dt>新报价版本</dt><dd>{{ $change->version?->version_code ?? '尚未生成' }}</dd></div><div><dt>签署状态</dt><dd>{{ $change->version?->signature_status ?? '未进入签署' }}</dd></div></dl><div class="quote-review__summary"><strong>变更依据与说明</strong><br>{{ $change->summary }}</div></div></section>
<section class="quote-review__panel" aria-labelledby="review-base-title"><header class="quote-review__header"><div class="quote-review__title" id="review-base-title"><span class="material-icons-outlined" aria-hidden="true">assignment</span><span>{{ $change->number }} · {{ $change->statusLabel() }}</span></div></header><div class="quote-review__body"><dl class="quote-review__meta"><div><dt>原报价版本</dt><dd>{{ $change->source_quote_id }}</dd></div><div><dt>快递公司</dt><dd>{{ $change->carrier_name }}</dd></div><div><dt>报价类型</dt><dd>{{ $change->quote_type }}</dd></div><div><dt>变更类型</dt><dd>{{ $change->change_type }}</dd></div><div><dt>执行期间</dt><dd>{{ $change->periodLabel() }}</dd></div><div><dt>生效方式</dt><dd>{{ $change->isRetrospective() ? '追溯变价' : '正常生效' }}</dd></div><div><dt>追溯核算</dt><dd>{{ $change->recalculationStatusLabel() }}</dd></div><div><dt>变价明细</dt><dd>{{ $requoteDiff ? ($requoteDiff['same_structure'] ? count($requoteDiff['cells']) . ' 处价格变动' : '计费方式调整') : $change->items->count() . ' 项' }}</dd></div><div><dt>新报价版本</dt><dd>{{ $change->version?->version_code ?? '尚未生成' }}</dd></div><div><dt>签署状态</dt><dd>{{ $change->version?->signature_status ?? '未进入签署' }}</dd></div></dl><div class="quote-review__summary"><strong>变更依据与说明</strong><br>{{ $change->summary }}</div></div></section>
@if($requoteDiff)
@php($fmt = static fn ($v) => $v === null ? '—' : rtrim(rtrim(number_format((float) $v, 4, '.', ''), '0'), '.'))
<section class="quote-review__panel" aria-labelledby="review-diff-title">
<header class="quote-review__header"><div class="quote-review__title" id="review-diff-title"><span class="material-icons-outlined" aria-hidden="true">difference</span><span>新旧报价对比 · {{ $change->change_type }}</span></div></header>
<div class="quote-review__body">
@if($requoteDiff['same_structure'])
<p style="margin:0 0 10px">计费方式不变:{{ $requoteDiff['new_mode'] }}。共 <strong>{{ count($requoteDiff['cells']) }}</strong> 处价格变动,未列出的价格不变。</p>
@else
<div class="quote-review__alert" role="status" style="margin:0 0 12px"><span class="material-icons-outlined" aria-hidden="true">swap_horiz</span><div><strong>计费方式调整</strong><br>原:{{ $requoteDiff['old_mode'] }}({{ implode(' / ', $requoteDiff['old_columns']) }})<br>新:{{ $requoteDiff['new_mode'] }}({{ implode(' / ', $requoteDiff['new_columns']) }})<br>新报价全表见下方「新报价」。</div></div>
@endif
@if($requoteDiff['added'] || $requoteDiff['removed'])
<p style="margin:0 0 10px">@if($requoteDiff['added'])新增省份:{{ implode('、', $requoteDiff['added']) }}。@endif @if($requoteDiff['removed'])不再报价:{{ implode('、', $requoteDiff['removed']) }}。@endif</p>
@endif
@foreach($requoteDiff['rules'] as $rule)
<p style="margin:0 0 6px">规则:{{ $rule['old'] }} → <strong>{{ $rule['new'] }}</strong></p>
@endforeach
</div>
@if($requoteDiff['same_structure'] && $requoteDiff['cells'])
<div class="quote-review__table-wrap"><table><thead><tr><th>省份</th><th>计费项</th><th class="quote-review__number">原价格</th><th class="quote-review__number">新价格</th><th class="quote-review__number">差额</th></tr></thead><tbody>
@foreach($requoteDiff['cells'] as $cell)
@php($delta = (float) ($cell['new'] ?? 0) - (float) ($cell['old'] ?? 0))
<tr><td><strong>{{ $cell['region'] }}</strong></td><td>{{ $cell['label'] }}</td><td class="quote-review__number">{{ $fmt($cell['old']) }}</td><td class="quote-review__number"><strong>{{ $fmt($cell['new']) }}</strong></td><td class="quote-review__number quote-review__delta" style="color:{{ $delta > 0 ? '#A1281F' : '#2F6B2A' }}">{{ $delta > 0 ? '+' : '' }}{{ $fmt($delta) }}</td></tr>
@endforeach
</tbody></table></div>
@endif
</section>
@php($proposed = json_decode((string) $change->proposed_values_json, true) ?: [])
@php($proposedTable = \App\Http\Controllers\Business\CourierQuoteSignatures::quoteTable($proposed))
@if($proposedTable)
<section class="quote-review__panel" aria-labelledby="review-new-title">
<header class="quote-review__header"><div class="quote-review__title" id="review-new-title"><span class="material-icons-outlined" aria-hidden="true">table_view</span><span>新报价({{ $change->end_date ? $change->effective_date->toDateString() . ' 至 ' . $change->end_date->toDateString() . ' 临时执行,到期恢复原报价' : $change->effective_date->toDateString() . ' 起执行' }})</span></div></header>
<div class="quote-review__table-wrap"><table>
<thead><tr>@foreach($proposedTable[0] as $i => $head)<th @class(['quote-review__number' => $i > 0])>{{ $head }}</th>@endforeach</tr></thead>
<tbody>@foreach(array_slice($proposedTable, 1) as $row)<tr>@foreach($row as $i => $value)<td @class(['quote-review__number' => $i > 0])>{{ $i === 0 ? $value : $fmt($value) }}</td>@endforeach</tr>@endforeach</tbody>
</table></div>
</section>
@endif
@else
<section class="quote-review__panel" aria-labelledby="review-diff-title"><header class="quote-review__header"><div class="quote-review__title" id="review-diff-title"><span class="material-icons-outlined" aria-hidden="true">difference</span><span>新旧价格差异</span></div></header><div class="quote-review__table-wrap"><table><thead><tr><th>单元格</th><th>地区/规则</th><th>重量段/计费项</th><th class="quote-review__number">原价格</th><th class="quote-review__number">新价格</th><th class="quote-review__number">差额</th><th>备注</th></tr></thead><tbody>@foreach($change->items as $item)@php($delta=(float)$item->new_amount-(float)$item->original_amount)<tr><td><strong>{{ $item->source_cell }}</strong></td><td>{{ $item->region ?: '—' }}</td><td>{{ $item->weight_band ?: ($item->charge_item ?: '—') }}</td><td class="quote-review__number">{{ rtrim(rtrim($item->original_amount,'0'),'.') }}</td><td class="quote-review__number">{{ rtrim(rtrim($item->new_amount,'0'),'.') }}</td><td class="quote-review__number quote-review__delta">{{ $delta>0?'+':'' }}{{ number_format($delta,4,'.','') }}</td><td>{{ $item->note ?: '—' }}</td></tr>@endforeach</tbody></table></div></section>
@endif
@if($change->version)
@php($quoteSignature=$change->version->signature)
<section class="quote-review__panel" aria-labelledby="review-signature-title">
<header class="quote-review__header"><div class="quote-review__title" id="review-signature-title"><span class="material-icons-outlined" aria-hidden="true">draw</span><span>E 签宝签署与归档</span></div></header>
<div class="quote-review__body">
@if($change->version->isVoided())
<div class="quote-review__alert" role="status" style="margin:0 0 14px;border-color:#E6B7B7;background:#FFF4F4;color:#8A1515"><span class="material-icons-outlined" aria-hidden="true">block</span><div><strong>{{ $change->version->version_code }} 已作废</strong><br>原因:{{ $change->version->void_reason }}<br>{{ $change->version->voided_at?->format('Y-m-d H:i') }}{{ $voidedBy ? ' · ' . $voidedBy : '' }}</div></div>
@endif
@if($onboarding && $canManageSignature && !$change->version->isVoided() && (!$quoteSignature || $quoteSignature->canSend()))
<form class="quote-review__form" method="POST" action="{{ route('business.courier-quote-changes.push-courier',$change) }}" style="margin-bottom:14px">@csrf
<div class="quote-review__form-actions">
<span class="quote-review__hint">已入驻快递,按入驻资料签署:{{ $onboarding->isPerson() ? '个人 ' : $onboarding->company_full_name . ' · ' }}{{ $onboarding->esign_operator_name }} {{ $onboarding->esign_account }}</span>
<button class="quote-review__button quote-review__button--primary" type="submit" @disabled(!$esignConfigured)><span class="material-icons-outlined" aria-hidden="true">send</span><span>{{ $quoteSignature && $quoteSignature->esign_sign_flow_id ? '重新推送到快递 e签宝' : '推送到快递 e签宝' }}</span></button>
</div>
</form>
@endif
@if(!$quoteSignature)
<div class="quote-review__hint">该历史报价版本尚未生成签署原文。</div>
@if($canManageSignature)<form class="quote-review__form" method="POST" action="{{ route('business.courier-quote-signatures.prepare',$change->version) }}">@csrf<div class="quote-review__form-grid"><div class="quote-review__field"><label for="prepare_signature_date">签署日期</label><input class="quote-review__control" id="prepare_signature_date" name="signature_date" type="date" value="{{ old('signature_date',now()->toDateString()) }}" required></div></div><div class="quote-review__form-actions"><span class="quote-review__hint">日期将直接写入待签原文。</span><button class="quote-review__button quote-review__button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">picture_as_pdf</span><span>生成签署 PDF</span></button></div></form>@endif
@ -91,7 +142,7 @@
@if($quoteSignature->certificate_path)<a class="quote-review__button" href="{{ route('business.courier-quote-signatures.download',[$quoteSignature,'certificate']) }}"><span class="material-icons-outlined" aria-hidden="true">workspace_premium</span><span>完成证书</span></a>@endif
@if($canManageSignature && $quoteSignature->esign_sign_flow_id && $quoteSignature->status!=='archived')<form method="POST" action="{{ route('business.courier-quote-signatures.synchronize',$quoteSignature) }}">@csrf<button class="quote-review__button" type="submit"><span class="material-icons-outlined" aria-hidden="true">sync</span><span>同步签署状态</span></button></form>@endif
</div>
@if($canManageSignature && $quoteSignature->canSend())
@if($canManageSignature && $quoteSignature->canSend() && !$onboarding && !$change->version->isVoided())
<form class="quote-review__form" method="POST" action="{{ route('business.courier-quote-signatures.send',$quoteSignature) }}">@csrf
<div class="quote-review__form-grid">
<div class="quote-review__field"><label for="organization_name">签署企业全称</label><input class="quote-review__control" id="organization_name" name="organization_name" value="{{ old('organization_name',$quoteSignature->organization_name) }}" maxlength="255" required></div>
@ -108,6 +159,12 @@
</form>
@endif
@endif
@if($canManageSignature && !$change->version->isVoided())
<form class="quote-review__form" method="POST" action="{{ route('business.courier-quote-changes.void',$change) }}" style="margin-top:16px;padding-top:14px;border-top:1px dashed #E6DFD0" onsubmit="return confirm('确认作废 {{ $change->version->version_code }}?{{ $quoteSignature && in_array($quoteSignature->status, ['archived','completed'], true) ? '快递已签署,作废后需另发一版正确报价让快递重新签。' : '会同时撤回快递 e签宝 里的待签任务。' }}')">@csrf
<div class="quote-review__field"><label for="void_reason">发错了?作废此版本(填写原因)</label><input class="quote-review__control" id="void_reason" name="void_reason" maxlength="500" placeholder="如:江苏面单多打了一位;执行日期填错" value="{{ old('void_reason') }}" required></div>
<div class="quote-review__form-actions"><span class="quote-review__hint">{{ $quoteSignature && in_array($quoteSignature->status, ['archived','completed'], true) ? '快递已签署:作废只在我方停用此版本,回签单保留,之后请发起一版正确的变价让快递重新签。' : '未签完:作废会撤回快递 e签宝 里的待签任务,此版本不再生效。' }}</span><button class="quote-review__button" type="submit" style="color:#A1281F;border-color:#E6B7B7"><span class="material-icons-outlined" aria-hidden="true">block</span><span>作废此版本</span></button></div>
</form>
@endif
</div>
</section>
@endif

View File

@ -1,4 +1,9 @@
@php
try {
app(\App\Support\CourierQuoteSignatureService::class)->refreshPending();
} catch (\Throwable $exception) {
report($exception);
}
$quoteSnapshot = app(\App\Support\QuoteManagementSnapshot::class)->load();
$quotes = $quoteSnapshot['quotes'];
$quoteCompanies = collect($quotes)
@ -360,12 +365,45 @@
color: #53793E;
}
.quote-badge--muted {
border-color: #DDD8CE;
background: #F4F2EE;
color: #8A8478;
}
.quote-badge--attention {
border-color: #D7B77D;
background: #FFF8E9;
color: #935F07;
}
.quote-signed {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin: 0.75rem 1rem 0;
padding: 0.6rem 0.8rem;
border: 1px solid #CFE3C8;
border-radius: 0.5rem;
background: #F1F8EE;
color: #2F6B2A;
}
.quote-modal__panel > .quote-detail-table-wrap {
grid-row: 4;
}
.quote-signed[hidden],
.quote-signed [hidden] {
display: none;
}
.quote-signed__text {
margin-right: auto;
font-weight: 600;
}
.quote-action {
min-width: 3.2rem;
border: 0;
@ -452,7 +490,7 @@
.quote-modal__panel {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
grid-template-rows: auto auto auto minmax(0, 1fr);
width: min(78rem, calc(100vw - 3rem));
height: 100%;
background: #FFFFFF;
@ -727,6 +765,12 @@
<button class="quote-icon-button" type="button" title="重置筛选" aria-label="重置筛选" data-quote-reset>
<span class="material-icons-outlined" aria-hidden="true">filter_alt_off</span>
</button>
@if ($quoteSnapshot['available'])
<a class="quote-button" href="{{ route('business.courier-quote-signatures.export') }}" title="把当前筛选结果里已签署的回签单 PDF 和签署完成证书打包下载" data-quote-export>
<span class="material-icons-outlined" aria-hidden="true">download</span>
<span data-quote-export-label>导出回签单</span>
</a>
@endif
<a class="quote-button" href="{{ route('business.courier-quote-changes.index') }}" title="查看报价变更草稿、审核状态和新版本">
<span class="material-icons-outlined" aria-hidden="true">history</span>
<span>报价历史</span>
@ -818,6 +862,13 @@
<div><dt>执行日期</dt><dd data-quote-detail="date"></dd></div>
<div><dt>公式单元格</dt><dd data-quote-detail="formulas"></dd></div>
</dl>
<div class="quote-signed" data-quote-signed hidden>
<span class="material-icons-outlined" aria-hidden="true">task_alt</span>
<span class="quote-signed__text" data-quote-signed-text>已签署回传</span>
<a class="quote-button" data-quote-signed-link="signed"><span class="material-icons-outlined" aria-hidden="true">picture_as_pdf</span><span>回签单 PDF</span></a>
<a class="quote-button" data-quote-signed-link="certificate"><span class="material-icons-outlined" aria-hidden="true">verified</span><span>签署完成证书</span></a>
<a class="quote-button" data-quote-signed-link="export" title="导出报价表(xlsx,WPS / Excel 可打开),只含省份价格"><span class="material-icons-outlined" aria-hidden="true">table_view</span><span>导出电子档</span></a>
</div>
<div class="quote-detail-table-wrap">
<table class="quote-detail-table" data-quote-detail-table></table>
</div>
@ -843,6 +894,15 @@
var tableWrap = root.querySelector('.quote-table-wrap');
var pagination = root.querySelector('[data-quote-pagination]');
var modal = root.querySelector('[data-quote-modal]');
var exportBase = @json(route('business.courier-quote-signatures.export'));
var exportAnchor = root.querySelector('[data-quote-export]');
if (exportAnchor) {
exportAnchor.addEventListener('click', function (event) {
if (exportAnchor.getAttribute('aria-disabled') === 'true') {
event.preventDefault();
}
});
}
var lastFocusedElement = null;
var fields = {
keyword: root.querySelector('[data-quote-filter="keyword"]'),
@ -934,15 +994,32 @@
appendCell(row, quote.quote_type);
appendCell(row, text(quote.source_range, '-'));
appendCell(row, quote.row_count + ' 行 × ' + quote.column_count + ' 列');
appendCell(row, text(quote.effective_date, '-'));
appendCell(row, quote.signature_status, 'quote-table__secondary');
appendCell(row, text(quote.period_label || quote.effective_date, '-'));
var signCell = appendCell(row, quote.signature_status, 'quote-table__secondary');
if (quote.signed_files && quote.signed_files.signed) {
signCell.appendChild(document.createElement('br'));
var signedLink = create('a', 'quote-action', '回签单');
signedLink.href = quote.signed_files.signed;
signedLink.title = '下载 ' + quote.id + ' 已签署的回签单 PDF' + (quote.signed_at ? '(' + quote.signed_at + ' 签署)' : '');
signedLink.setAttribute('aria-label', '下载回签单 ' + quote.id);
signCell.appendChild(signedLink);
}
var statusCell = appendCell(row, '');
statusCell.appendChild(create(
'span',
'quote-badge ' + (quote.effective_date ? 'quote-badge--ready' : 'quote-badge--attention'),
'quote-badge ' + (['已失效', '已作废', '已到期', '暂停中'].indexOf(quote.status) !== -1 ? 'quote-badge--muted' : (quote.effective_date ? 'quote-badge--ready' : 'quote-badge--attention')),
quote.status
));
if (quote.void_reason) {
statusCell.appendChild(create('div', 'quote-table__secondary', '作废:' + quote.void_reason));
}
if (quote.superseded_by) {
statusCell.appendChild(create('div', 'quote-table__secondary', '被 ' + quote.superseded_by + ' 替代'));
}
if (quote.status_note) {
statusCell.appendChild(create('div', 'quote-table__secondary', quote.status_note));
}
var actionCell = appendCell(row, '');
var action = create('button', 'quote-action', '查看');
@ -951,10 +1028,12 @@
action.setAttribute('aria-label', '查看 ' + quote.sheet_name);
actionCell.appendChild(action);
var changeAction = create('a', 'quote-action', '变价');
changeAction.href = @json(route('business.courier-quote-changes.create')) + '?source_quote_id=' + encodeURIComponent(quote.id);
changeAction.href = quote.requote_url || (@json(route('business.courier-quote-changes.create')) + '?source_quote_id=' + encodeURIComponent(quote.id));
changeAction.title = '发起 ' + quote.sheet_name + ' 的变更草稿';
changeAction.setAttribute('aria-label', '变价 ' + quote.sheet_name);
actionCell.appendChild(changeAction);
if (quote.status !== '已作废') {
actionCell.appendChild(changeAction);
}
return row;
}
@ -969,6 +1048,15 @@
});
root.querySelector('[data-quote-result-count]').textContent = '共 ' + filteredQuotes.length + ' 个报价版本';
var exportLink = root.querySelector('[data-quote-export]');
if (exportLink) {
var signedCodes = filteredQuotes.filter(function (quote) { return quote.signed_files && quote.signed_files.signed; })
.map(function (quote) { return quote.id; });
exportLink.href = exportBase + (signedCodes.length ? '?codes=' + encodeURIComponent(signedCodes.join(',')) : '');
exportLink.setAttribute('aria-disabled', signedCodes.length ? 'false' : 'true');
exportLink.style.opacity = signedCodes.length ? '' : '0.48';
exportLink.querySelector('[data-quote-export-label]').textContent = '导出回签单' + (signedCodes.length ? '(' + signedCodes.length + ')' : '');
}
root.querySelector('[data-quote-page-label]').textContent = filteredQuotes.length
? '共 ' + filteredQuotes.length + ' 条 · 共 ' + pageCount + ' 页 · 当前第 ' + currentPage + ' 页'
: '共 0 条 · 共 0 页';
@ -1025,8 +1113,17 @@
setDetail('carrier', quote.company_name);
setDetail('type', quote.quote_type);
setDetail('range', text(quote.source_range, '-'));
setDetail('date', text(quote.effective_date, 'Excel 未标注'));
setDetail('date', text(quote.period_label || quote.effective_date, 'Excel 未标注'));
setDetail('formulas', String(quote.formula_count));
var signedBox = root.querySelector('[data-quote-signed]');
var files = quote.signed_files || {};
signedBox.hidden = !files.signed;
root.querySelector('[data-quote-signed-text]').textContent = '已签署回传' + (quote.signed_at ? ' · ' + quote.signed_at : '');
signedBox.querySelectorAll('[data-quote-signed-link]').forEach(function (link) {
var url = files[link.getAttribute('data-quote-signed-link')];
link.hidden = !url;
if (url) { link.href = url; } else { link.removeAttribute('href'); }
});
var table = root.querySelector('[data-quote-detail-table]');
var head = create('thead');

View File

@ -89,6 +89,9 @@
</tbody>
</table>
@if(! empty($temporaryNote))
<div class="notice"><strong>{!! $mixedText($temporaryNote) !!}</strong></div>
@endif
<div class="notice">本文件由财务系统根据已审核报价版本自动生成。乙方签章后文件与完成证书将按本版本单独归档;后续变价须生成新版本并重新签章,旧确认单不会被覆盖。</div>
@if($change)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,23 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>入驻链接不可用</title>
<style>
body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 24px 16px; background: #F8F6F1; color: #171717; font: 15px/1.6 "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; }
.box { width: 100%; max-width: 420px; padding: 28px 22px; border: 1px solid #D8D2C7; border-radius: 12px; background: #fff; text-align: center; }
.icon { display: inline-grid; width: 48px; height: 48px; margin-bottom: 12px; place-items: center; border-radius: 50%; background: #F7F0DE; color: #856632; font-size: 24px; font-weight: 700; }
h1 { margin: 0 0 8px; font-size: 19px; }
p { margin: 0; color: #595959; font-size: 14px; }
</style>
</head>
<body>
<div class="box" role="alert">
<div class="icon" aria-hidden="true">!</div>
<h1>入驻链接无效或已作废</h1>
<p>请联系我方对接人重新获取入驻二维码。</p>
</div>
</body>
</html>

View File

@ -1,9 +1,11 @@
<?php
use App\Http\Controllers\Business\AccountMappingActions;
use App\Http\Controllers\Business\CourierProfiles;
use App\Http\Controllers\Business\CourierQuoteChanges;
use App\Http\Controllers\Business\CourierQuoteSignatures;
use App\Http\Controllers\Business\CourierAgreementSignatures;
use App\Http\Controllers\Business\CourierOnboardings;
use App\Http\Controllers\Business\CourierPayableSummaries;
use App\Http\Controllers\Business\CourierSurchargeQuotes;
use App\Http\Controllers\Business\CourierSurchargeTypes;
@ -134,18 +136,46 @@ Route::prefix('workspace/courier-payables/quote-management/changes')
Route::post('{quoteChange}/submit', [CourierQuoteChanges::class, 'submit'])->name('submit');
Route::post('{quoteChange}/approve', [CourierQuoteChanges::class, 'approve'])->name('approve');
Route::post('{quoteChange}/return', [CourierQuoteChanges::class, 'returnForChanges'])->name('return');
Route::post('{quoteChange}/push-courier', [CourierQuoteChanges::class, 'pushToCourier'])->name('push-courier');
Route::post('{quoteChange}/void', [CourierQuoteChanges::class, 'void'])->name('void');
});
Route::prefix('workspace/courier-payables/quote-management/signatures')
->name('business.courier-quote-signatures.')
->middleware('business.page:courier-payables,quote-management')
->group(function () {
Route::get('export', [CourierQuoteSignatures::class, 'export'])->name('export');
Route::get('versions/{quoteVersion}/sheet', [CourierQuoteSignatures::class, 'sheet'])->name('sheet');
Route::post('versions/{quoteVersion}/prepare', [CourierQuoteSignatures::class, 'prepare'])->name('prepare');
Route::post('{quoteSignature}/send', [CourierQuoteSignatures::class, 'send'])->name('send');
Route::post('{quoteSignature}/synchronize', [CourierQuoteSignatures::class, 'synchronize'])->name('synchronize');
Route::get('{quoteSignature}/files/{kind}', [CourierQuoteSignatures::class, 'download'])->name('download');
});
Route::prefix('workspace/courier-payables/courier-onboarding/applications')
->name('business.courier-onboardings.')
->middleware('business.page:courier-payables,courier-onboarding')
->group(function (): void {
Route::post('/', [CourierOnboardings::class, 'store'])->name('store');
Route::get('{onboarding}', [CourierOnboardings::class, 'show'])->name('show');
Route::get('{onboarding}/qr', [CourierOnboardings::class, 'qr'])->name('qr');
Route::get('{onboarding}/files/{file}', [CourierOnboardings::class, 'file'])->name('file');
Route::post('{onboarding}/first-approve', [CourierOnboardings::class, 'firstApprove'])->name('first-approve');
Route::post('{onboarding}/return', [CourierOnboardings::class, 'returnForChanges'])->name('return');
Route::post('{onboarding}/reject', [CourierOnboardings::class, 'reject'])->name('reject');
Route::post('{onboarding}/cancel', [CourierOnboardings::class, 'cancel'])->name('cancel');
Route::post('{onboarding}/extend', [CourierOnboardings::class, 'extend'])->name('extend');
Route::post('{onboarding}/internal', [CourierOnboardings::class, 'updateInternal'])->name('internal');
Route::post('{onboarding}/erp-opening', [CourierOnboardings::class, 'erpOpening'])->name('erp-opening');
Route::post('{onboarding}/final-approve', [CourierOnboardings::class, 'finalApprove'])->name('final-approve');
Route::post('{onboarding}/activate', [CourierOnboardings::class, 'activate'])->name('activate');
Route::post('{onboarding}/push-quote', [CourierOnboardings::class, 'pushQuote'])->name('push-quote');
Route::post('{onboarding}/sync-quote', [CourierOnboardings::class, 'syncQuote'])->name('sync-quote');
Route::post('{onboarding}/sign-url', [CourierOnboardings::class, 'signUrl'])->name('sign-url');
Route::get('{onboarding}/requote', [CourierOnboardings::class, 'requote'])->name('requote');
Route::post('{onboarding}/requote', [CourierOnboardings::class, 'storeRequote'])->name('requote.store');
});
Route::prefix('workspace/courier-payables/agreement-signatures')
->name('business.courier-agreement-signatures.')
->middleware('business.page:courier-payables,agreement-signing')
@ -338,6 +368,15 @@ Route::get('workspace/refunds/small-payment-work-orders/export', 'Business\Pages
->middleware('business.page:financial-operations,small-payment-work-orders')
->name('business.refunds.small-payment-work-orders.export');
Route::prefix('workspace/courier-payables/courier-information/profiles')
->name('business.courier-profiles.')
->middleware('business.page:courier-payables,courier-information')
->group(function (): void {
Route::post('payment-accounts', [CourierProfiles::class, 'storePaymentAccountForCode'])->name('payment-accounts.store-any');
Route::post('{expcode}', [CourierProfiles::class, 'update'])->name('update')->where('expcode', '[0-9A-Za-z_\\-]+');
Route::post('{expcode}/payment-accounts', [CourierProfiles::class, 'storePaymentAccount'])->name('payment-accounts.store')->where('expcode', '[0-9A-Za-z_\\-]+');
});
Route::get('workspace/courier-payables/courier-information/export', 'Business\Pages@exportCourierInformation')
->middleware('business.page:courier-payables,courier-information')
->name('business.courier-payables.courier-information.export');

View File

@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\Integrations\EsignCallbacks;
use App\Http\Controllers\Onboarding\CourierOnboardingForms;
use Illuminate\Support\Facades\Route;
use Livewire\Livewire;
@ -14,6 +15,16 @@ Route::post('integrations/esign/callback', EsignCallbacks::class)
->middleware('throttle:120,1')
->name('integrations.esign.callback');
// 快递入驻:快递方扫码打开的免登录填报页,凭链接里的随机 token 访问。
Route::get('onboarding/courier/{token}', [CourierOnboardingForms::class, 'show'])
->middleware('throttle:60,1')
->where('token', '[A-Za-z0-9]{32,64}')
->name('courier-onboarding.form');
Route::post('onboarding/courier/{token}', [CourierOnboardingForms::class, 'update'])
->middleware('throttle:20,1')
->where('token', '[A-Za-z0-9]{32,64}')
->name('courier-onboarding.update');
Livewire::setScriptRoute(function ($handle) {
$base = request()->getBasePath();

View File

@ -31,6 +31,7 @@ class CourierInformationPageTest extends FeatureTestCase
->get(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'courier-information',
'courier_tab' => 'express',
]))
->assertOk()
->assertSeeText('快递资料')
@ -78,6 +79,7 @@ class CourierInformationPageTest extends FeatureTestCase
->get(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'courier-information',
'courier_tab' => 'express',
'expcode' => 'SF',
'status' => '1',
]))

File diff suppressed because it is too large Load Diff

View File

@ -168,6 +168,7 @@ class QuoteManagementPageTest extends FeatureTestCase
$quoteDirectory = collect($courierPayables['pages'])->firstWhere('slug', 'courier-quote');
$this->assertSame([
'quote-management',
'courier-onboarding',
'courier-information',
'agreement-signing',
'courier-surcharge',