CaiWuXiTong/resources/views/onboarding/courier-form.blade.php
limeng 71652c5a64 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
2026-10-14 13:47:00 +08:00

1027 lines
74 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters

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

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

@php
/** @var \App\Models\Business\CourierOnboarding $onboarding */
// 后台变价复用本页的报价区:$requote 为后台变价上下文(只显示报价,提交到后台)。
$requote = $requote ?? null;
$editable = $requote !== null || $onboarding->courierCanEdit();
$value = static fn (string $key, $default = null) => old($key, $onboarding->{$key} ?? $default);
$savedRows = collect($onboarding->quoteRowList())->keyBy('region');
$oldRows = old('quote_rows');
$quoteRows = [];
$customRows = $savedRows->except($provinces)->values()->all();
foreach ([...$provinces, null, null, null, null] as $index => $province) {
$row = is_array($oldRows) ? ($oldRows[$index] ?? []) : ($province !== null ? ($savedRows->get($province) ?? []) : ($customRows[$index - count($provinces)] ?? []));
$quoteRows[] = [
'region' => $province ?? ($row['region'] ?? ''),
'fixed' => $province !== null,
'flat' => array_values((array) ($row['flat'] ?? [])),
'base' => $row['base'] ?? null,
'rates' => array_values((array) ($row['rates'] ?? [])),
];
}
$structure = $onboarding->quoteStructure();
$kgList = static fn (array $list): string => implode(',', array_map(static fn ($v) => \App\Support\CourierQuoteStructure::kg((float) $v), $list));
$quoteSheet = $onboarding->files->where('kind', \App\Models\Business\CourierOnboardingFile::QUOTE_SHEET_KIND)->last();
$filesByKind = $onboarding->files->groupBy('kind');
$quoteMode = $value('quote_mode', 'waybill_rate');
$entityChosen = $requote !== null || filled(old('entity_type')) || filled($onboarding->company_full_name) || $onboarding->status !== 'invited' || $onboarding->files->isNotEmpty();
$entityType = $entityChosen ? $value('entity_type', 'company') : null;
$statusTone = match ($onboarding->status) {
'returned' => 'warning',
'activated' => 'success',
'rejected' => 'danger',
default => 'info',
};
$num = static fn ($v) => $v === null || $v === '' ? '' : rtrim(rtrim(number_format((float) $v, 4, '.', ''), '0'), '.');
@endphp
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="robots" content="noindex, nofollow">
<meta name="format-detection" content="telephone=no">
<title>{{ $requote ? '快递变价' : '快递合作入驻' }}</title>
<style>
:root {
--ink: #171717; --muted: #595959; --gold: #856632; --gold-strong: #6A5129; --gold-soft: #F7F0DE;
--gold-line: #D7C39D; --rail: #050506; --rail-gold: #F2E7C5; --bg: #F8F6F1; --surface: #FFFFFF;
--border: #D8D2C7; --success: #53793E; --danger: #B80000; --warning: #935F07; --info: #006395;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.55 "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; }
.hero { padding: 22px 16px 18px; background: var(--rail); color: var(--rail-gold); }
.hero__inner, .main { max-width: 720px; margin: 0 auto; }
.hero__eyebrow { margin: 0 0 6px; color: #BFAF86; font-size: 12px; letter-spacing: .08em; }
.hero h1 { margin: 0; font-size: 21px; font-weight: 700; letter-spacing: .02em; }
.hero p { margin: 8px 0 0; color: #DDD0AA; font-size: 13px; }
.hero__meta { display: flex; flex-wrap: wrap; gap: 6px 14px; margin-top: 12px; color: #BFAF86; font-size: 12px; font-variant-numeric: tabular-nums; }
.main { padding: 14px 16px 120px; }
.notice { display: flex; gap: 10px; margin-bottom: 12px; padding: 12px 14px; border: 1px solid var(--gold-line); border-radius: 10px; background: #FFFAF0; font-size: 14px; }
.notice strong { display: block; margin-bottom: 2px; }
.notice--success { border-color: #B7CDA8; background: #F1F7EC; color: #2F4A22; }
.notice--danger { border-color: #E3B0B0; background: #FDF1F1; color: #7A1010; }
.notice--warning { border-color: #E2C68C; background: #FFF6E2; color: #6A4505; }
.notice--info { border-color: #B4CFDE; background: #EEF6FA; color: #0A4560; }
.notice ul { margin: 6px 0 0; padding-left: 18px; }
.steps { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin: 0 0 14px; padding: 0; list-style: none; font-size: 12px; text-align: center; }
.steps li { padding: 7px 4px; border-radius: 8px; background: #EFEAE0; color: var(--muted); }
.steps li.is-done { background: var(--gold-soft); color: var(--gold); font-weight: 600; }
.steps li.is-current { background: var(--gold); color: #fff; font-weight: 600; }
.card { margin-bottom: 12px; overflow: hidden; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); }
.card__head { display: flex; align-items: baseline; gap: 8px; padding: 13px 16px; border-bottom: 1px solid var(--border); }
.card__index { display: inline-grid; flex: none; width: 22px; height: 22px; place-items: center; border-radius: 50%; background: var(--gold-soft); color: var(--gold); font-size: 12px; font-weight: 700; }
.card__title { margin: 0; font-size: 16px; font-weight: 700; }
.card__hint { margin-left: auto; color: var(--muted); font-size: 12px; }
.card__body { display: grid; gap: 14px; padding: 16px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.field { display: grid; gap: 6px; min-width: 0; }
.field > label, .field > .label { color: var(--ink); font-size: 14px; font-weight: 600; }
.req { color: var(--danger); margin-left: 2px; }
.help { color: var(--muted); font-size: 12px; }
.error { color: var(--danger); font-size: 13px; }
input[type=text], input[type=tel], input[type=number], input[type=date], select, textarea {
width: 100%; min-height: 44px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px;
background: #fff; color: var(--ink); font: inherit; font-size: 16px; appearance: none;
}
select { background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%); background-position: calc(100% - 18px) 50%, calc(100% - 13px) 50%; background-size: 5px 5px; background-repeat: no-repeat; padding-right: 32px; }
textarea { min-height: 84px; resize: vertical; }
input:focus, select:focus, textarea:focus { border-color: var(--gold); outline: none; box-shadow: 0 0 0 3px rgba(133, 102, 50, .18); }
input:disabled, select:disabled, textarea:disabled { background: #F4F1EA; color: var(--muted); }
.has-error input, .has-error select, .has-error textarea { border-color: var(--danger); }
.choices { display: flex; flex-wrap: wrap; gap: 8px; }
.choice { position: relative; }
.choice input { position: absolute; opacity: 0; pointer-events: none; }
.choice span { display: inline-flex; align-items: center; min-height: 40px; padding: 8px 14px; border: 1px solid var(--border); border-radius: 999px; background: #fff; font-size: 14px; }
.choice input:checked + span { border-color: var(--gold); background: var(--gold-soft); color: var(--gold-strong); font-weight: 600; }
.choice input:focus-visible + span { box-shadow: 0 0 0 3px rgba(133, 102, 50, .25); }
.quote-tools { display: grid; grid-template-columns: 1fr 1fr 1fr auto; gap: 8px; align-items: end; padding: 12px; border: 1px dashed var(--gold-line); border-radius: 10px; background: #FFFDF8; }
.quote-tools .field > label { font-size: 12px; font-weight: 500; color: var(--muted); }
.quote-table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 10px; -webkit-overflow-scrolling: touch; }
.quote-table { width: max-content; min-width: 100%; border-collapse: collapse; font-size: 14px; }
.quote-table td:first-child, .quote-table th:first-child { position: sticky; left: 0; z-index: 1; background: #fff; }
.quote-table th:first-child { background: var(--gold-soft); }
.quote-table th { position: sticky; top: 0; padding: 9px 8px; background: var(--gold-soft); color: var(--gold-strong); font-size: 12px; font-weight: 700; text-align: left; white-space: nowrap; }
.quote-table td { padding: 5px 6px; border-top: 1px solid #ECE8E0; }
.quote-table th:first-child, .quote-table td:first-child { width: 4.6em; padding-left: 10px; font-weight: 600; white-space: nowrap; }
.quote-table th { white-space: normal; line-height: 1.3; }
.quote-table input { min-height: 38px; padding: 6px; font-variant-numeric: tabular-nums; }
.quote-table tr.is-custom td:first-child { padding-left: 6px; }
.quote-table tr.is-custom td:first-child input { font-weight: 600; }
.upload { display: grid; gap: 8px; padding: 12px; border: 1px solid var(--border); border-radius: 10px; background: #FFFDF8; }
.upload__head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.upload__title { font-size: 14px; font-weight: 600; }
.upload input[type=file] { width: 100%; font-size: 14px; }
.upload input[type=file]::file-selector-button { margin-right: 10px; min-height: 36px; padding: 0 14px; border: 1px solid var(--gold); border-radius: 8px; background: #fff; color: var(--gold-strong); font: inherit; font-weight: 600; }
.file-list { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }
.file-list li { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 8px; background: #fff; border: 1px solid #ECE8E0; font-size: 13px; }
.file-list .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-list .size { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.file-list label { display: inline-flex; align-items: center; gap: 4px; color: var(--danger); font-size: 12px; }
.actionbar { position: fixed; right: 0; bottom: 0; left: 0; z-index: 10; padding: 10px 16px calc(10px + env(safe-area-inset-bottom)); border-top: 1px solid var(--border); background: rgba(255, 255, 255, .96); backdrop-filter: blur(8px); }
.actionbar__inner { display: grid; grid-template-columns: 1fr 2fr; gap: 10px; max-width: 720px; margin: 0 auto; }
.btn { display: inline-flex; align-items: center; justify-content: center; min-height: 48px; padding: 0 16px; border: 1px solid var(--border); border-radius: 10px; background: #fff; color: var(--ink); font: inherit; font-size: 16px; font-weight: 600; cursor: pointer; }
.btn--primary { border-color: var(--gold); background: var(--gold); color: #fff; }
.btn--primary:active { background: var(--gold-strong); }
.btn[disabled] { opacity: .6; }
.summary { display: grid; gap: 0; margin: 0; }
.summary div { display: grid; grid-template-columns: 7.5em 1fr; gap: 10px; padding: 9px 0; border-bottom: 1px solid #F0ECE4; font-size: 14px; }
.summary div:last-child { border-bottom: 0; }
.summary dt { color: var(--muted); }
.summary dd { margin: 0; word-break: break-all; }
.footer-note { margin: 18px 0 0; color: var(--muted); font-size: 12px; text-align: center; }
.card__body > *, .structure > *, .quote-tools > * { min-width: 0; }
.quote-table-wrap { max-width: 100%; }
.mode-list { display: grid; gap: 8px; }
.mode-option { position: relative; display: block; cursor: pointer; }
.mode-option input { position: absolute; opacity: 0; pointer-events: none; }
.mode-option > span { display: grid; gap: 3px; padding: 11px 14px; border: 1px solid var(--border); border-radius: 10px; background: #fff; }
.mode-option strong { font-size: 15px; }
.mode-option small { color: var(--muted); font-size: 12px; line-height: 1.5; }
.mode-option input:checked + span { border-color: var(--gold); background: var(--gold-soft); box-shadow: inset 3px 0 0 var(--gold); }
.mode-option input:checked + span strong { color: var(--gold-strong); }
.structure { display: grid; gap: 14px; }
.account { display: grid; gap: 12px; padding: 12px; border: 1px solid var(--border); border-radius: 10px; }
.account__title { margin: 0; font-weight: 700; font-size: 15px; }
.account__tag { margin-left: 6px; padding: 1px 6px; border-radius: 6px; background: #F1EEE6; color: var(--muted); font-size: 12px; font-weight: 500; }
.account__check { font-size: 12px; }
.account__check.is-ok { color: #2F6B2A; }
.account__check.is-bad { color: #A1281F; }
.band-list { display: grid; gap: 8px; }
.band-row { display: grid; grid-template-columns: 44px 40px 92px auto 40px; gap: 8px; align-items: center; font-size: 15px; }
.band-row__no { color: var(--muted); font-size: 13px; }
.band-row input { width: 92px; }
.band-row__del { min-height: 40px; padding: 0; border: 1px solid var(--border); border-radius: 8px; background: #fff; color: var(--muted); font-size: 18px; cursor: pointer; }
.band-add { justify-self: start; min-height: 40px; font-size: 15px; }
[data-mode="none"] .structure, [data-mode="other"] .structure { display: none; }
.quote-table th { min-width: 84px; }
.quote-table td input[data-q-slot] { width: 76px; min-width: 76px; }
.quote-table td input[data-q-region] { width: 78px; }
.quote-table th { white-space: normal; max-width: 96px; }
.upload-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.upload { gap: 6px; padding: 10px; }
.upload__title { font-size: 13px; }
.upload__drop { position: relative; display: block; cursor: pointer; }
.upload__drop input[type=file] { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
.upload__preview { display: grid; place-items: center; align-content: center; gap: 4px; height: 108px; overflow: hidden; border: 1px dashed var(--gold-line); border-radius: 8px; background: #fff; color: var(--muted); font-size: 12px; text-align: center; }
.upload__preview img { width: 100%; height: 100%; object-fit: cover; }
.upload__plus { color: var(--gold); font-size: 28px; line-height: 1; }
.upload__done { color: var(--success); font-weight: 700; }
.upload__name { max-width: 100%; padding: 0 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.upload__remove { display: inline-flex; align-items: center; gap: 4px; color: var(--danger); font-size: 12px; }
.chooser { display: grid; gap: 12px; margin-bottom: 12px; }
.chooser__title { margin: 4px 0 0; font-size: 17px; font-weight: 700; }
.chooser__card { display: grid; grid-template-columns: 48px 1fr; gap: 12px; align-items: center; width: 100%; padding: 18px 16px; border: 1px solid var(--border); border-radius: 12px; background: #fff; color: var(--ink); font: inherit; text-align: left; cursor: pointer; }
.chooser__card:active { background: var(--gold-soft); }
.chooser__icon { display: grid; width: 48px; height: 48px; place-items: center; border-radius: 12px; background: var(--gold-soft); color: var(--gold); font-size: 22px; font-weight: 700; }
.chooser__card strong { display: block; font-size: 16px; }
.chooser__card span span { display: block; margin-top: 3px; color: var(--muted); font-size: 13px; }
[data-entity="none"] .card, [data-entity="none"] ~ .actionbar, [data-entity="none"] .actionbar { display: none !important; }
[data-entity="company"] [data-chooser], [data-entity="person"] [data-chooser] { display: none !important; }
[data-entity="company"] [data-entity-only="person"], [data-entity="person"] [data-entity-only="company"] { display: none !important; }
@media (max-width: 520px) {
.grid-2 { grid-template-columns: 1fr; }
.quote-tools { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.quote-tools .btn { grid-column: span 3; min-height: 42px; }
}
</style>
</head>
<body>
@if ($requote)
<header class="hero">
<div class="hero__inner">
<p class="hero__eyebrow"><a href="{{ $requote['back'] }}" style="color:inherit">← 返回报价管理 · 快递报价</a></p>
<h1>{{ $requote['courier'] }} · 变价</h1>
<p>以当前报价为底稿修改,可以只改价格,也可以改计费方式和重量分档。提交后需另一人审核,审核通过自动生成新报价版本,并推送到快递的 e签宝签署。</p>
<div class="hero__meta">
<span>底稿版本 {{ $requote['version']?->version_code ?? '入驻首版' }}</span>
@if ($requote['change'])<span>修改已退回的变价单 {{ $requote['change']->number }}</span>@endif
<span>签署人 {{ $requote['signer'] }}</span>
</div>
</div>
</header>
@else
<header class="hero">
<div class="hero__inner">
<p class="hero__eyebrow">{{ company()?->name ? company()->name . ' · ' : '' }}快递合作</p>
<h1>快递合作入驻资料</h1>
<p>请如实填写贵司主体、结算账户和报价,提交后我方会在 1–3 个工作日内审核。</p>
<div class="hero__meta">
<span>申请编号 {{ $onboarding->number }}</span>
@if ($onboarding->invite_label)<span>{{ $onboarding->invite_label }}</span>@endif
@if ($editable && $onboarding->expires_at)<span>链接有效期至 {{ $onboarding->expires_at->format('Y-m-d') }}</span>@endif
</div>
</div>
</header>
@endif
<main class="main">
@php
$stepIndex = match ($onboarding->status) {
'invited', 'returned' => 0,
'submitted', 'first_approved' => 1,
'approved' => 2,
'activated' => 3,
default => 1,
};
@endphp
@unless ($requote)
<ol class="steps" aria-label="入驻进度">
@foreach (['填写资料', '我方审核', '签署协议', '开通合作'] as $i => $step)
<li class="{{ $i < $stepIndex ? 'is-done' : ($i === $stepIndex ? 'is-current' : '') }}" @if ($i === $stepIndex) aria-current="step" @endif>{{ $step }}</li>
@endforeach
</ol>
@endunless
@if (session('onboarding_success'))
<div class="notice notice--success" role="status"><div><strong>{{ session('onboarding_success') }}</strong></div></div>
@endif
@if (request()->boolean('too_large'))
<div class="notice notice--danger" role="alert"><div><strong>图片太大,这次没有保存成功</strong>请重新选择照片,或每次少选几张、先点「暂存」再继续上传。</div></div>
@endif
@if ($errors->any())
<div class="notice notice--danger" role="alert">
<div>
<strong>还有 {{ count($errors->all()) }} 项需要处理</strong>
<ul>@foreach (array_slice($errors->all(), 0, 8) as $message)<li>{{ $message }}</li>@endforeach</ul>
</div>
</div>
@endif
@if ($requote)
@if ($requote['change'] && $requote['change']->status === 'returned')
<div class="notice notice--warning" role="status"><div><strong>变价单 {{ $requote['change']->number }} 已被退回</strong>按审核意见修改后重新提交即可。</div></div>
@endif
@elseif ($onboarding->status === 'returned' && $onboarding->return_reason)
<div class="notice notice--warning" role="status"><div><strong>需要补充或修改</strong>{{ $onboarding->return_reason }}</div></div>
@elseif (! $editable)
<div class="notice notice--{{ $statusTone }}" role="status">
<div>
<strong>当前状态:{{ $onboarding->linkExpired() && in_array($onboarding->status, ['invited', 'returned'], true) ? '链接已过期' : $onboarding->statusLabel() }}</strong>
@switch ($onboarding->status)
@case('submitted')
@case('first_approved')
资料已提交,审核中。如需修改,请联系我方对接人退回后再改。
@break
@case('approved')
审核已通过,报价确认单已推送到你填写的 e签宝账号,请留意短信或邮件,登录 e签宝完成签署。
@break
@case('activated')
合作已开通,感谢配合。
@break
@case('rejected')
很抱歉,本次暂未达成合作。
@break
@default
链接已过期,请联系我方对接人重新获取二维码。
@endswitch
</div>
</div>
@endif
<form method="POST" action="{{ $requote ? $requote['action'] : route('courier-onboarding.update', ['token' => $token]) }}" enctype="multipart/form-data" novalidate data-onboarding-form data-entity="{{ $entityType === null ? 'none' : ($entityType === 'person' ? 'person' : 'company') }}">
@csrf
@if ($editable && ! $requote)
<div class="chooser" data-chooser>
<p class="chooser__title">请选择入驻方式</p>
<button type="button" class="chooser__card" data-choose-entity="company">
<span class="chooser__icon" aria-hidden="true">企</span>
<span><strong>公司 / 个体户入驻</strong><span>有营业执照,以公司名义合作和签署</span></span>
</button>
<button type="button" class="chooser__card" data-choose-entity="person">
<span class="chooser__icon" aria-hidden="true">人</span>
<span><strong>个人入驻</strong><span>以个人身份合作,用本人身份证实名签署</span></span>
</button>
</div>
@endif
<fieldset @disabled(! $editable) style="margin:0;padding:0;border:0;min-width:0">
@unless ($requote)
<section class="card" aria-labelledby="sec-company">
<div class="card__head"><span class="card__index">1</span><h2 class="card__title" id="sec-company">入驻主体</h2><span class="card__hint" data-entity-only="company">与营业执照一致</span><span class="card__hint" data-entity-only="person">与身份证一致</span></div>
<div class="card__body">
<div class="field">
<span class="label">入驻类型<span class="req">*</span></span>
<div class="choices" role="radiogroup" aria-label="入驻类型">
@foreach (\App\Models\Business\CourierOnboarding::ENTITY_TYPES as $type => $label)
<label class="choice"><input type="radio" name="entity_type" value="{{ $type }}" @checked($entityType === $type) data-entity-type><span>{{ $label }}</span></label>
@endforeach
</div>
<span class="help" data-entity-only="company">有营业执照的公司或个体工商户选这个,以公司名义签署。</span>
<span class="help" data-entity-only="person">没有营业执照、以个人身份合作选这个,用本人身份证实名签署。</span>
</div>
<div class="field @error('company_full_name') has-error @enderror">
<label for="company_full_name"><span data-entity-only="company">公司全称</span><span data-entity-only="person">姓名</span><span class="req">*</span></label>
<input id="company_full_name" name="company_full_name" type="text" maxlength="255" value="{{ $value('company_full_name') }}" data-placeholder-company="如:常州某某快递服务有限公司" data-placeholder-person="与身份证一致">
@error('company_full_name')<span class="error">{{ $message }}</span>@enderror
</div>
<div class="field @error('id_number') has-error @enderror" data-entity-only="person">
<label for="id_number">身份证号<span class="req">*</span></label>
<input id="id_number" name="id_number" type="text" maxlength="18" autocapitalize="characters" autocomplete="off" value="{{ old('id_number', $onboarding->id_number) }}" placeholder="18 位">
@error('id_number')<span class="error">{{ $message }}</span>@enderror
</div>
<div class="grid-2" data-entity-only="company">
<div class="field @error('unified_social_credit_code') has-error @enderror">
<label for="unified_social_credit_code">统一社会信用代码<span class="req">*</span></label>
<input id="unified_social_credit_code" name="unified_social_credit_code" type="text" maxlength="18" autocapitalize="characters" value="{{ $value('unified_social_credit_code') }}" placeholder="18 位">
@error('unified_social_credit_code')<span class="error">{{ $message }}</span>@enderror
</div>
<div class="field @error('legal_representative') has-error @enderror">
<label for="legal_representative">法定代表人</label>
<input id="legal_representative" name="legal_representative" type="text" maxlength="100" value="{{ $value('legal_representative') }}">
</div>
</div>
<div class="field">
<label for="business_address">经营地址</label>
<input id="business_address" name="business_address" type="text" maxlength="255" autocomplete="street-address" value="{{ $value('business_address') }}">
</div>
<div class="grid-2">
<div class="field @error('carrier_brand') has-error @enderror">
<label for="carrier_brand">快递品牌<span class="req">*</span></label>
<input id="carrier_brand" name="carrier_brand" type="text" maxlength="50" list="carrier-brands" value="{{ $value('carrier_brand') }}" placeholder="如:中通、圆通、德邦">
<datalist id="carrier-brands">@foreach (['中通', '圆通', '申通', '韵达', '极兔', '德邦', '顺丰', '京东', '邮政', '百世快运', '安能', '中通快运', '壹米滴答', '跨越'] as $brand)<option value="{{ $brand }}">@endforeach</datalist>
@error('carrier_brand')<span class="error">{{ $message }}</span>@enderror
</div>
<div class="field @error('network_name') has-error @enderror">
<label for="network_name">网点名称<span class="req">*</span></label>
<input id="network_name" name="network_name" type="text" maxlength="100" value="{{ $value('network_name') }}" placeholder="如:常州新北网点">
@error('network_name')<span class="error">{{ $message }}</span>@enderror
</div>
</div>
<div class="field @error('service_type') has-error @enderror">
<span class="label">业务类型<span class="req">*</span></span>
<div class="choices" role="radiogroup" aria-label="业务类型">
@foreach (\App\Models\Business\CourierOnboarding::SERVICE_TYPES as $type)
<label class="choice"><input type="radio" name="service_type" value="{{ $type }}" @checked($value('service_type') === $type)><span>{{ $type }}</span></label>
@endforeach
</div>
@error('service_type')<span class="error">{{ $message }}</span>@enderror
</div>
<div class="field">
<label for="pickup_warehouses">可上门揽收的仓库 / 区域</label>
<textarea id="pickup_warehouses" name="pickup_warehouses" maxlength="1000" placeholder="如:常州新北区、武进横林仓">{{ $value('pickup_warehouses') }}</textarea>
</div>
</div>
</section>
<section class="card" aria-labelledby="sec-contact">
<div class="card__head"><span class="card__index">2</span><h2 class="card__title" id="sec-contact">联系人</h2></div>
<div class="card__body">
<div class="grid-2">
<div class="field @error('contact_name') has-error @enderror">
<label for="contact_name">业务对接人<span class="req">*</span></label>
<input id="contact_name" name="contact_name" type="text" maxlength="100" autocomplete="name" value="{{ $value('contact_name') }}">
@error('contact_name')<span class="error">{{ $message }}</span>@enderror
</div>
<div class="field @error('contact_phone') has-error @enderror">
<label for="contact_phone">对接人手机<span class="req">*</span></label>
<input id="contact_phone" name="contact_phone" type="tel" inputmode="tel" maxlength="20" autocomplete="tel" value="{{ $value('contact_phone') }}">
<span class="help">用于接收合作协议的电子签署短信</span>
@error('contact_phone')<span class="error">{{ $message }}</span>@enderror
</div>
</div>
<div class="grid-2">
<div class="field">
<label for="finance_contact_name">财务对账人</label>
<input id="finance_contact_name" name="finance_contact_name" type="text" maxlength="100" value="{{ $value('finance_contact_name') }}">
</div>
<div class="field @error('finance_contact_phone') has-error @enderror">
<label for="finance_contact_phone">对账人电话</label>
<input id="finance_contact_phone" name="finance_contact_phone" type="tel" inputmode="tel" maxlength="20" value="{{ $value('finance_contact_phone') }}">
@error('finance_contact_phone')<span class="error">{{ $message }}</span>@enderror
</div>
</div>
</div>
</section>
<section class="card" aria-labelledby="sec-esign">
<div class="card__head"><span class="card__index">3</span><h2 class="card__title" id="sec-esign">e签宝电子签署</h2><span class="card__hint">报价确认单、合作协议都发到这里</span></div>
<div class="card__body">
<p class="help" style="margin:0">审核通过后,系统会按你填写的报价自动生成报价确认单,推送到下面这个 e签宝账号,签署人用该账号登录 e签宝(或点短信链接)即可盖章签署,无需再纸质盖章寄回。</p>
<div class="grid-2">
<div class="field @error('esign_operator_name') has-error @enderror" data-entity-only="company">
<label for="esign_operator_name">签署人姓名<span class="req">*</span></label>
<input id="esign_operator_name" name="esign_operator_name" type="text" maxlength="100" value="{{ $value('esign_operator_name') }}" placeholder="e签宝实名认证的姓名" data-default-from="contact_name">
@error('esign_operator_name')<span class="error">{{ $message }}</span>@enderror
</div>
<div class="field @error('esign_account') has-error @enderror">
<label for="esign_account">e签宝账号<span class="req">*</span></label>
<input id="esign_account" name="esign_account" type="text" inputmode="email" maxlength="255" autocomplete="off" value="{{ $value('esign_account') }}" placeholder="注册 e签宝用的手机号或邮箱" data-default-from="contact_phone">
<span class="help">手机号会收到签署短信,邮箱会收到签署邮件</span>
@error('esign_account')<span class="error">{{ $message }}</span>@enderror
</div>
</div>
<p class="help" style="margin:0" data-entity-only="company">签署主体为上面填写的公司全称,签署人需有该公司在 e签宝的签章权限。</p>
<p class="help" style="margin:0" data-entity-only="person">个人入驻由本人签署,e签宝账号需用本人身份证完成实名认证。</p>
</div>
</section>
<section class="card" aria-labelledby="sec-settle">
<div class="card__head"><span class="card__index">4</span><h2 class="card__title" id="sec-settle">结算与开票</h2><span class="card__hint">运费打款账户(对公 / 对私)</span></div>
<div class="card__body">
<div class="account" data-account="corporate">
<p class="account__title">对公账户<span data-entity-only="company"><span class="req">*</span></span><span class="account__tag" data-entity-only="person">选填</span></p>
<div class="field @error('bank_account_name') has-error @enderror">
<label for="bank_account_name">对公户名</label>
<input id="bank_account_name" name="bank_account_name" type="text" maxlength="255" value="{{ $value('bank_account_name') }}" data-placeholder-company="须与营业执照公司全称一致" data-placeholder-person="有个体户或公司账户可填" data-account-name="corporate">
<span class="account__check" data-account-check="corporate" aria-live="polite"></span>
@error('bank_account_name')<span class="error">{{ $message }}</span>@enderror
</div>
<div class="grid-2">
<div class="field @error('bank_name') has-error @enderror">
<label for="bank_name">开户行</label>
<input id="bank_name" name="bank_name" type="text" maxlength="255" value="{{ $value('bank_name') }}" placeholder="写到支行">
@error('bank_name')<span class="error">{{ $message }}</span>@enderror
</div>
<div class="field @error('bank_account_number') has-error @enderror">
<label for="bank_account_number">对公账号</label>
<input id="bank_account_number" name="bank_account_number" type="text" inputmode="numeric" maxlength="40" autocomplete="off" value="{{ old('bank_account_number', $onboarding->bank_account_number) }}">
@error('bank_account_number')<span class="error">{{ $message }}</span>@enderror
</div>
</div>
</div>
<div class="account" data-account="private">
<p class="account__title">对私账户<span class="req">*</span></p>
<div class="field @error('private_account_name') has-error @enderror">
<label for="private_account_name">对私收款人</label>
<input id="private_account_name" name="private_account_name" type="text" maxlength="255" value="{{ $value('private_account_name') }}" data-placeholder-company="须为营业执照上的法定代表人" data-placeholder-person="须为本人" data-account-name="private">
<span class="account__check" data-account-check="private" aria-live="polite"></span>
@error('private_account_name')<span class="error">{{ $message }}</span>@enderror
</div>
<div class="grid-2">
<div class="field @error('private_bank_name') has-error @enderror">
<label for="private_bank_name">开户行</label>
<input id="private_bank_name" name="private_bank_name" type="text" maxlength="255" value="{{ $value('private_bank_name') }}" placeholder="写到支行">
@error('private_bank_name')<span class="error">{{ $message }}</span>@enderror
</div>
<div class="field @error('private_account_number') has-error @enderror">
<label for="private_account_number">银行卡号</label>
<input id="private_account_number" name="private_account_number" type="text" inputmode="numeric" maxlength="40" autocomplete="off" value="{{ old('private_account_number', $onboarding->private_account_number) }}">
@error('private_account_number')<span class="error">{{ $message }}</span>@enderror
</div>
</div>
<span class="help">这张银行卡的正反面请在下方「证件照片」上传。</span>
</div>
<div class="field @error('invoice_type') has-error @enderror">
<span class="label">开票类型<span class="req">*</span></span>
<div class="choices">
@foreach (\App\Models\Business\CourierOnboarding::INVOICE_TYPES as $type)
<label class="choice"><input type="radio" name="invoice_type" value="{{ $type }}" @checked($value('invoice_type') === $type)><span>{{ $type }}</span></label>
@endforeach
</div>
@error('invoice_type')<span class="error">{{ $message }}</span>@enderror
</div>
<div class="grid-2">
<div class="field @error('tax_rate') has-error @enderror">
<label for="tax_rate">税率(%)</label>
<input id="tax_rate" name="tax_rate" type="number" inputmode="decimal" step="0.01" min="0" max="20" value="{{ $num($value('tax_rate')) }}" placeholder="如 6 或 1">
@error('tax_rate')<span class="error">{{ $message }}</span>@enderror
</div>
<div class="field @error('settlement_cycle') has-error @enderror">
<label for="settlement_cycle">结算周期<span class="req">*</span></label>
<select id="settlement_cycle" name="settlement_cycle">
<option value="">请选择</option>
@foreach (\App\Models\Business\CourierOnboarding::SETTLEMENT_CYCLES as $cycle)
<option value="{{ $cycle }}" @selected($value('settlement_cycle') === $cycle)>{{ $cycle }}</option>
@endforeach
</select>
@error('settlement_cycle')<span class="error">{{ $message }}</span>@enderror
</div>
</div>
</div>
</section>
@endunless
<section class="card" aria-labelledby="sec-quote" data-quote-section>
<div class="card__head"><span class="card__index">{{ $requote ? 1 : 5 }}</span><h2 class="card__title" id="sec-quote">报价</h2><span class="card__hint">单位:元</span></div>
<div class="card__body">
<div class="field @error('quote_mode') has-error @enderror">
<span class="label">① 计费方式<span class="req">*</span></span>
<div class="mode-list" role="radiogroup" aria-label="计费方式">
@foreach (\App\Support\CourierQuoteStructure::MODES as $mode => $label)
@continue($requote && $mode === 'other')
<label class="mode-option">
<input type="radio" name="quote_mode" value="{{ $mode }}" @checked($value('quote_mode') === $mode) data-quote-mode>
<span><strong>{{ $label }}</strong><small>{{ \App\Support\CourierQuoteStructure::MODE_HINTS[$mode] }}</small></span>
</label>
@endforeach
</div>
@error('quote_mode')<span class="error">{{ $message }}</span>@enderror
</div>
<div class="structure" data-structure>
<span class="label" data-show-for="waybill_rate first_additional flat_bands flat_then">② 计费结构</span>
<div class="field @error('quote_flat_bands') has-error @enderror" data-show-for="flat_bands flat_then">
<span class="label">一口价重量档<span class="req">*</span></span>
<input id="quote_flat_bands" name="quote_flat_bands" type="hidden" value="{{ old('quote_flat_bands', $kgList($structure['flat_bands'])) }}" data-band-value>
<div class="band-list" data-band-list></div>
<button type="button" class="btn band-add" data-band-add>+ 添加一档</button>
<span class="help">每档填上限重量,含上限。例如依次填 1、2、3,就是 0-1kg(含1)、1-2kg(含2)、2-3kg(含3) 三档。</span>
<span class="help" data-show-for="flat_bands">超过最后一档还要按「面单+费率×重量」或「首重+续重」计费的,请改选上面的「小重量一口价 + 超出另算」。</span>
@error('quote_flat_bands')<span class="error">{{ $message }}</span>@enderror
</div>
<div class="field" data-show-for="flat_then">
<span class="label">超出一口价部分怎么算<span class="req">*</span></span>
<div class="choices">
<label class="choice"><input type="radio" name="quote_then_mode" value="waybill_rate" @checked(old('quote_then_mode', $structure['then_mode'] ?: 'waybill_rate') === 'waybill_rate') data-structure-input><span>面单 + 费率×重量</span></label>
<label class="choice"><input type="radio" name="quote_then_mode" value="first_additional" @checked(old('quote_then_mode', $structure['then_mode']) === 'first_additional') data-structure-input><span>首重 + 续重</span></label>
</div>
</div>
<div class="field @error('quote_first_weight') has-error @enderror" data-show-for="first_additional flat_then:first_additional">
<label for="quote_first_weight">首重公斤数<span class="req">*</span></label>
<input id="quote_first_weight" name="quote_first_weight" type="number" inputmode="decimal" min="0" step="0.01" value="{{ old('quote_first_weight', $structure['first_weight'] !== null ? \App\Support\CourierQuoteStructure::kg($structure['first_weight']) : '') }}" placeholder="如 1、3、5、20" data-structure-input>
@error('quote_first_weight')<span class="error">{{ $message }}</span>@enderror
</div>
<div class="field @error('quote_rate_tiers') has-error @enderror" data-show-for="waybill_rate first_additional flat_then">
<label for="quote_rate_tiers"><span data-rate-name>费率</span>按重量分段(选填,kg)</label>
<input id="quote_rate_tiers" name="quote_rate_tiers" type="text" inputmode="decimal" maxlength="200" value="{{ old('quote_rate_tiers', $kgList($structure['rate_tiers'])) }}" placeholder="不分段就留空;如 35,50" data-structure-input>
<span class="help">「35,50」表示 0-35kg、35-50kg、50kg以上三档,按总重量所在区间取价</span>
@error('quote_rate_tiers')<span class="error">{{ $message }}</span>@enderror
</div>
</div>
<div data-show-for="waybill_rate first_additional flat_bands flat_then">
<span class="label">③ 各省价格</span>
<div class="quote-tools" data-fill-tools></div>
<p class="help" style="margin:6px 0 10px">全国统一价先在上面填好,点「填充到全部省份」(会覆盖已填的价格),个别省份再单独改;不发的省份整行留空。{{ $requote ? '审核通过后会生成新的报价确认单推送给快递签署,请仔细核对。' : '这份报价会直接生成报价确认单发给你签署,请仔细核对。' }}</p>
@error('quote_rows')<span class="error">{{ $message }}</span>@enderror
<p class="help" style="margin:0 0 6px" data-scroll-hint>列较多时左右滑动表格查看,省份一列固定不动</p>
<div class="quote-table-wrap">
<table class="quote-table"><thead data-quote-head></thead><tbody data-quote-body></tbody></table>
</div>
<script type="application/json" data-quote-rows>{!! json_encode($quoteRows, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) !!}</script>
</div>
<div class="upload @error('files.quote_sheet') has-error @enderror" data-show-for="other" data-upload>
<div class="upload__head"><span class="upload__title">报价表<span class="req">*</span></span></div>
<label class="upload__drop">
<span class="upload__preview" data-upload-preview>
@if ($quoteSheet)
<span class="upload__done">已上传</span><span class="upload__name">{{ $quoteSheet->original_name }}</span>
@else
<span class="upload__plus" aria-hidden="true">+</span><span class="upload__name">上传 Excel、PDF 或照片</span>
@endif
</span>
@if ($editable)<input type="file" name="files[quote_sheet][]" accept="image/*,.pdf,.xls,.xlsx" aria-label="上传报价表" data-upload-input>@endif
</label>
@error('files.quote_sheet')<span class="error">{{ $message }}</span>@enderror
</div>
<span class="label">④ 附加规则</span>
<div class="grid-2">
<div class="field @error('volumetric_rule') has-error @enderror">
<span class="label">是否计泡<span class="req">*</span></span>
<div class="choices">
@foreach (\App\Models\Business\CourierOnboarding::VOLUMETRIC_RULES as $rule)
<label class="choice"><input type="radio" name="volumetric_rule" value="{{ $rule }}" @checked($value('volumetric_rule') === $rule) data-volumetric-rule><span>{{ $rule }}</span></label>
@endforeach
</div>
@error('volumetric_rule')<span class="error">{{ $message }}</span>@enderror
</div>
<div class="field @error('volumetric_divisor') has-error @enderror" data-volumetric-divisor>
<label for="volumetric_divisor">计泡系数</label>
<input id="volumetric_divisor" name="volumetric_divisor" type="number" inputmode="numeric" min="1000" max="20000" step="1" value="{{ $value('volumetric_divisor') }}" placeholder="长×宽×高 ÷ 6000">
@error('volumetric_divisor')<span class="error">{{ $message }}</span>@enderror
</div>
</div>
<div class="grid-2">
<div class="field @error('min_weight') has-error @enderror">
<label for="min_weight">起算重量(kg,选填)</label>
<input id="min_weight" name="min_weight" type="number" inputmode="decimal" min="0" step="0.01" value="{{ $num($value('min_weight')) }}" placeholder="如不足 10kg 按 10kg 算,填 10">
</div>
<div class="field">
<label for="weight_rounding">重量取整</label>
<select id="weight_rounding" name="weight_rounding">
@foreach (\App\Support\CourierQuoteStructure::ROUNDING as $key => $label)
<option value="{{ $key }}" @selected($value('weight_rounding', 'none') === $key)>{{ $label }}</option>
@endforeach
</select>
</div>
</div>
<div class="grid-2">
<div class="field @error('per_ticket_surcharge') has-error @enderror">
<label for="per_ticket_surcharge">每票固定加收(元,选填)</label>
<input id="per_ticket_surcharge" name="per_ticket_surcharge" type="number" inputmode="decimal" min="0" step="0.01" value="{{ $num($value('per_ticket_surcharge')) }}" placeholder="如每票 +1 元,填 1">
</div>
<div class="field @error('quote_effective_date') has-error @enderror">
<label for="quote_effective_date">报价执行日期<span class="req">*</span></label>
<input id="quote_effective_date" name="quote_effective_date" type="date" value="{{ old('quote_effective_date', $onboarding->quote_effective_date?->format('Y-m-d')) }}">
@error('quote_effective_date')<span class="error">{{ $message }}</span>@enderror
</div>
</div>
@if (! empty($requote))
<div class="grid-2">
<div class="field @error('quote_end_date') has-error @enderror">
<label for="quote_end_date">结束日期(选填 · 短期变价)</label>
<input id="quote_end_date" name="quote_end_date" type="date" value="{{ old('quote_end_date', ($requote['change'] ?? null)?->end_date?->format('Y-m-d')) }}">
<span class="help">留空 = 长期执行。填了就是临时价:只在执行日期~结束日期期间用,结束后第二天自动恢复原来的长期报价,快递不用再签。</span>
@error('quote_end_date')<span class="error">{{ $message }}</span>@enderror
</div>
</div>
@endif
<div class="field">
<label for="quote_note">报价备注</label>
<textarea id="quote_note" name="quote_note" maxlength="2000" placeholder="如:限长限重、超长费、上楼费、偏远地区加价、深圳等城市单独价格">{{ $value('quote_note') }}</textarea>
</div>
</div>
</section>
@if ($requote)
<section class="card" aria-labelledby="sec-summary">
<div class="card__head"><span class="card__index">2</span><h2 class="card__title" id="sec-summary">变价说明</h2></div>
<div class="card__body">
<div class="field @error('summary') has-error @enderror">
<label for="summary">变价原因 / 依据<span class="req">*</span></label>
<textarea id="summary" name="summary" maxlength="2000" placeholder="如:快递通知 11 月起江浙沪面单上调 0.3 元;改为 0-1、1-2、2-3kg 三档一口价">{{ $requote['summary'] }}</textarea>
@error('summary')<span class="error">{{ $message }}</span>@enderror
</div>
</div>
</section>
@else
<section class="card" aria-labelledby="sec-files">
<div class="card__head"><span class="card__index">6</span><h2 class="card__title" id="sec-files">上传证件照片</h2><span class="card__hint">可直接拍照,系统会自动压缩</span></div>
<div class="card__body">
<div class="upload-grid">
@foreach ($fileKinds as $kind => $label)
@php $existing = $filesByKind->get($kind, collect())->last(); @endphp
<div class="upload @error('files.' . $kind) has-error @enderror" data-upload>
<div class="upload__head">
<span class="upload__title">
<span data-entity-only="company">{{ $label }}@if (in_array($kind, $requiredKinds, true))<span class="req">*</span>@endif</span>
<span data-entity-only="person">{{ $personKinds[$kind] ?? $label }}@if (in_array($kind, $personRequiredKinds, true))<span class="req">*</span>@endif</span>
</span>
</div>
<label class="upload__drop">
<span class="upload__preview" data-upload-preview>
@if ($existing)
<span class="upload__done">已上传</span><span class="upload__name">{{ $existing->original_name }}</span>
@else
<span class="upload__plus" aria-hidden="true">+</span><span class="upload__name">拍照或选择图片</span>
@endif
</span>
@if ($editable)
<input type="file" name="files[{{ $kind }}][]" accept="image/*,.pdf" aria-label="上传{{ $label }}" data-upload-input>
@endif
</label>
@if ($existing && $editable && ! in_array($kind, $requiredKinds, true))
<label class="upload__remove"><input type="checkbox" name="remove_files[]" value="{{ $existing->id }}">删除已上传</label>
@endif
@error('files.' . $kind)<span class="error">{{ $message }}</span>@enderror
</div>
@endforeach
</div>
<p class="help" style="margin:0" data-upload-total></p>
<div class="field">
<label for="remark">其他说明</label>
<textarea id="remark" name="remark" maxlength="2000">{{ $value('remark') }}</textarea>
</div>
</div>
</section>
@endif
</fieldset>
@if ($requote)
<div class="actionbar">
<div class="actionbar__inner">
<a class="btn" href="{{ $requote['back'] }}">返回</a>
<button type="submit" class="btn btn--primary" data-submit-button>提交审核</button>
</div>
</div>
@elseif ($editable)
<div class="actionbar">
<div class="actionbar__inner">
<button type="submit" name="action" value="save" class="btn" data-submit-button>暂存</button>
<button type="submit" name="action" value="submit" class="btn btn--primary" data-submit-button>提交审核</button>
</div>
</div>
@endif
</form>
@unless ($requote)
<p class="footer-note">本页面仅用于快递合作入驻,资料仅用于合作审核与结算。<br>如有疑问请联系我方对接人。</p>
@endunless
</main>
<script>
(function () {
var form = document.querySelector('[data-onboarding-form]');
if (!form) return;
function applyVolumetric() {
var checked = form.querySelector('[data-volumetric-rule]:checked');
var box = form.querySelector('[data-volumetric-divisor]');
if (box) box.style.display = checked && checked.value === '计泡' ? '' : 'none';
}
function applyEntity() {
var checked = form.querySelector('[data-entity-type]:checked');
var type = checked ? checked.value : 'company';
form.setAttribute('data-entity', type);
form.querySelectorAll('[data-entity-only]').forEach(function (el) {
var show = el.getAttribute('data-entity-only') === type;
el.querySelectorAll('input, select, textarea').forEach(function (input) { if (!input.closest('fieldset[disabled]')) input.disabled = !show; });
});
form.querySelectorAll('[data-placeholder-' + type + ']').forEach(function (el) { el.placeholder = el.getAttribute('data-placeholder-' + type); });
}
form.querySelectorAll('[data-entity-type]').forEach(function (el) { el.addEventListener('change', applyEntity); });
form.querySelectorAll('[data-choose-entity]').forEach(function (button) {
button.addEventListener('click', function () {
var radio = form.querySelector('[data-entity-type][value="' + button.getAttribute('data-choose-entity') + '"]');
if (radio) { radio.checked = true; }
applyEntity();
window.scrollTo(0, 0);
});
});
if (form.querySelector('[data-entity-type]:checked')) applyEntity();
// 手机原图往往 3-8MB,上传前在本机压缩到 1600px 左右的 JPEG,避免超过服务器上传上限。
var MAX_TOTAL = 11 * 1024 * 1024;
function compress(file) {
return new Promise(function (resolve) {
if (!/^image\//.test(file.type) || file.size < 600 * 1024) { resolve(file); return; }
var url = URL.createObjectURL(file);
var img = new Image();
img.onload = function () {
var scale = Math.min(1, 1600 / Math.max(img.width, img.height));
var canvas = document.createElement('canvas');
canvas.width = Math.round(img.width * scale);
canvas.height = Math.round(img.height * scale);
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
URL.revokeObjectURL(url);
canvas.toBlob(function (blob) {
if (!blob || blob.size >= file.size) { resolve(file); return; }
resolve(new File([blob], file.name.replace(/\.[^.]+$/, '') + '.jpg', { type: 'image/jpeg' }));
}, 'image/jpeg', 0.82);
};
img.onerror = function () { URL.revokeObjectURL(url); resolve(file); };
img.src = url;
});
}
function totalSize() {
var total = 0;
form.querySelectorAll('[data-upload-input]').forEach(function (input) {
Array.prototype.forEach.call(input.files || [], function (f) { total += f.size; });
});
return total;
}
function refreshTotal() {
var note = form.querySelector('[data-upload-total]');
var total = totalSize();
if (note) note.textContent = total > 0 ? '本次待上传 ' + (total / 1048576).toFixed(1) + ' MB' + (total > MAX_TOTAL ? ',超过 11MB,请分两次暂存上传' : '') : '';
}
form.querySelectorAll('[data-upload-input]').forEach(function (input) {
input.addEventListener('change', function () {
var file = input.files && input.files[0];
var preview = input.closest('[data-upload]').querySelector('[data-upload-preview]');
if (!file) { refreshTotal(); return; }
preview.innerHTML = '<span class="upload__name">处理中…</span>';
compress(file).then(function (result) {
if (result !== file && window.DataTransfer) {
try { var dt = new DataTransfer(); dt.items.add(result); input.files = dt.files; } catch (e) { result = file; }
}
if (/^image\//.test(result.type)) {
var img = document.createElement('img');
img.src = URL.createObjectURL(result);
img.alt = '';
preview.innerHTML = '';
preview.appendChild(img);
} else {
preview.innerHTML = '<span class="upload__done">已选择</span><span class="upload__name"></span>';
preview.querySelector('.upload__name').textContent = result.name;
}
refreshTotal();
});
});
});
form.querySelectorAll('[data-default-from]').forEach(function (target) {
var source = form.querySelector('[name="' + target.getAttribute('data-default-from') + '"]');
if (!source) return;
source.addEventListener('change', function () { if (target.value === '') target.value = source.value; });
});
form.querySelectorAll('[data-volumetric-rule]').forEach(function (el) { el.addEventListener('change', applyVolumetric); });
applyVolumetric();
// ---- 打款账户:对公户名 = 公司全称;对私收款人 = 法定代表人(个人为本人) ----
function normName(v) { return String(v || '').replace(/(/g, '(').replace(/)/g, ')').replace(/[\s ]+/g, '').toUpperCase(); }
function checkAccounts() {
var person = form.getAttribute('data-entity') === 'person';
var company = (form.querySelector('[name=company_full_name]') || {}).value || '';
var legal = (form.querySelector('[name=legal_representative]') || {}).value || '';
[['corporate', person ? '' : company, '营业执照公司全称'], ['private', person ? company : legal, person ? '本人姓名' : '营业执照法定代表人']].forEach(function (item) {
var input = form.querySelector('[data-account-name="' + item[0] + '"]');
var note = form.querySelector('[data-account-check="' + item[0] + '"]');
if (!input || !note) return;
note.className = 'account__check';
if (!input.value.trim() || !item[1].trim()) { note.textContent = ''; return; }
var ok = normName(input.value) === normName(item[1]);
note.classList.add(ok ? 'is-ok' : 'is-bad');
note.textContent = ok ? '✓ 与' + item[2] + '一致' : '✗ 与' + item[2] + '「' + item[1].trim() + '」不一致,提交时会被拦下';
});
}
['company_full_name', 'legal_representative', 'bank_account_name', 'private_account_name'].forEach(function (name) {
var el = form.querySelector('[name=' + name + ']');
if (el) el.addEventListener('input', checkAccounts);
});
form.querySelectorAll('[data-entity-type], [data-choose-entity]').forEach(function (el) { el.addEventListener(el.tagName === 'BUTTON' ? 'click' : 'change', checkAccounts); });
checkAccounts();
// ---- 报价表:按计费方式和重量结构动态生成列 ----
var quoteSection = form.querySelector('[data-quote-section]');
var rowsScript = form.querySelector('[data-quote-rows]');
var model = rowsScript ? JSON.parse(rowsScript.textContent || '[]') : [];
var editableForm = !form.querySelector('fieldset[disabled]');
function kg(v) { return String(Math.round(v * 100) / 100); }
function parseList(text) {
var seen = {}, out = [];
String(text || '').split(/[\s,,、;;\/]+/).forEach(function (p) {
p = p.replace(/(kg|KG|公斤)$/, '');
var n = parseFloat(p);
if (!isNaN(n) && n > 0 && !seen[n]) { seen[n] = true; out.push(Math.round(n * 100) / 100); }
});
return out.sort(function (a, b) { return a - b; });
}
function structure() {
var mode = (form.querySelector('[data-quote-mode]:checked') || {}).value || '';
var thenMode = (form.querySelector('[name=quote_then_mode]:checked') || {}).value || 'waybill_rate';
return {
mode: mode,
thenMode: mode === 'flat_then' ? thenMode : '',
bands: (mode === 'flat_bands' || mode === 'flat_then') ? parseList(form.querySelector('[name=quote_flat_bands]').value).slice(0, 12) : [],
firstWeight: parseFloat(form.querySelector('[name=quote_first_weight]').value) || 0,
tiers: (mode === 'waybill_rate' || mode === 'first_additional' || mode === 'flat_then') ? parseList(form.querySelector('[name=quote_rate_tiers]').value).slice(0, 6) : []
};
}
function columns(st) {
var cols = [];
if (!st.mode || st.mode === 'other') return cols;
var lower = 0;
st.bands.forEach(function (upper, i) { cols.push({ key: 'flat', index: i, slot: 'flat:' + lower + '-' + upper, label: kg(lower) + '-' + kg(upper) + 'kg(含' + kg(upper) + ') 一口价' }); lower = upper; });
if (st.mode === 'flat_bands') return cols;
var first = st.mode === 'first_additional' || st.thenMode === 'first_additional';
var prefix = st.mode === 'flat_then' && st.bands.length ? kg(st.bands[st.bands.length - 1]) + 'kg 以上 ' : '';
cols.push({ key: 'base', slot: 'base', label: prefix + (first ? '首重' + kg(st.firstWeight) + 'kg(元)' : '面单') });
var name = first ? '续重' : '费率';
if (!st.tiers.length) { cols.push({ key: 'rates', index: 0, slot: 'rates:0', label: name + '(元/kg)' }); return cols; }
var start = st.mode === 'flat_then' && st.bands.length ? st.bands[st.bands.length - 1] : 0;
st.tiers.forEach(function (edge, i) { cols.push({ key: 'rates', index: i, slot: 'rates:' + i, label: name + ' ' + kg(i === 0 ? start : st.tiers[i - 1]) + '-' + kg(edge) + 'kg' }); });
cols.push({ key: 'rates', index: st.tiers.length, slot: 'rates:' + st.tiers.length, label: name + ' ' + kg(st.tiers[st.tiers.length - 1]) + 'kg以上' });
return cols;
}
// 每个价格按「计费方式 + 列含义」单独存放:换了计费方式,面单不会跑到首重价那一列;切回来原来填的还在。
function slotKey(st, col) { return st.mode + '|' + st.thenMode + '|' + col.slot; }
function readTable() {
if (!quoteSection) return;
quoteSection.querySelectorAll('[data-q-row]').forEach(function (tr) {
var row = model[+tr.getAttribute('data-q-row')];
var region = tr.querySelector('[data-q-region]');
if (region && region.type === 'text') row.region = region.value;
tr.querySelectorAll('[data-q-slot]').forEach(function (input) { row.vals[input.getAttribute('data-q-slot')] = input.value; });
});
}
function esc(t) { return String(t).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); }
function nameFor(i, col) { return 'quote_rows[' + i + '][' + col.key + ']' + (col.key === 'base' ? '' : '[' + col.index + ']'); }
// ---- 一口价重量档:逐档添加 / 删除,写回隐藏字段 quote_flat_bands ----
var bandValue = form.querySelector('[data-band-value]');
var bandList = form.querySelector('[data-band-list]');
var bandAdd = form.querySelector('[data-band-add]');
function bandInputs() { return bandList ? Array.prototype.slice.call(bandList.querySelectorAll('[data-band-input]')) : []; }
function syncBands() {
var inputs = bandInputs(), prev = '0';
bandValue.value = inputs.map(function (el) { return el.value; }).filter(function (v) { return v !== ''; }).join(',');
inputs.forEach(function (el, i) {
var row = el.closest('[data-band-row]');
row.querySelector('[data-band-no]').textContent = '第' + (i + 1) + '档';
row.querySelector('[data-band-lower]').textContent = prev + ' -';
prev = el.value !== '' ? el.value : '?';
});
}
function buildBands(values) {
var dis = editableForm ? '' : ' disabled';
bandList.innerHTML = values.map(function (v) {
return '<div class="band-row" data-band-row><span class="band-row__no" data-band-no></span><span data-band-lower></span>'
+ '<input type="number" inputmode="decimal" min="0" step="0.01" value="' + esc(v) + '" placeholder="上限" aria-label="本档上限重量" data-band-input' + dis + '>'
+ '<span>kg(含)</span>'
+ (editableForm ? '<button type="button" class="band-row__del" aria-label="删除这一档" data-band-del>×</button>' : '<span></span>') + '</div>';
}).join('');
bandInputs().forEach(function (el) { el.addEventListener('input', function () { syncBands(); readTable(); render(); }); });
bandList.querySelectorAll('[data-band-del]').forEach(function (btn) {
btn.addEventListener('click', function () {
var values = bandInputs().map(function (el) { return el.value; });
values.splice(bandInputs().indexOf(btn.closest('[data-band-row]').querySelector('[data-band-input]')), 1);
buildBands(values.length ? values : ['']);
syncBands(); readTable(); render();
});
});
syncBands();
}
if (bandList && bandValue) {
var initialBands = parseList(bandValue.value).map(kg);
buildBands(initialBands.length ? initialBands : ['']);
if (bandAdd) {
if (!editableForm) bandAdd.style.display = 'none';
bandAdd.addEventListener('click', function () {
var values = bandInputs().map(function (el) { return el.value; });
if (values.length >= 12) return;
values.push('');
buildBands(values);
var inputs = bandInputs();
inputs[inputs.length - 1].focus();
});
}
}
// 页面带回的已填报价(服务端按当前结构的 flat[] / base / rates[])转成按列含义存放
(function () {
var st0 = structure(), cols0 = columns(st0);
model.forEach(function (row) {
row.vals = {};
cols0.forEach(function (c) {
var v = c.key === 'base' ? row.base : (row[c.key] || [])[c.index];
if (v !== null && v !== undefined && v !== '') row.vals[slotKey(st0, c)] = String(v);
});
});
})();
function render() {
if (!quoteSection) return;
var st = structure();
quoteSection.setAttribute('data-mode', st.mode || 'none');
quoteSection.querySelectorAll('[data-show-for]').forEach(function (el) {
var tokens = el.getAttribute('data-show-for').split(' ');
var show = tokens.some(function (t) {
var parts = t.split(':');
return parts[0] === st.mode && (!parts[1] || parts[1] === st.thenMode);
});
el.style.display = show ? '' : 'none';
});
var rateName = quoteSection.querySelector('[data-rate-name]');
if (rateName) rateName.textContent = (st.mode === 'first_additional' || st.thenMode === 'first_additional') ? '续重' : '费率';
var cols = columns(st);
var head = quoteSection.querySelector('[data-quote-head]');
var body = quoteSection.querySelector('[data-quote-body]');
var tools = quoteSection.querySelector('[data-fill-tools]');
if (!head || !body) return;
var dis = editableForm ? '' : ' disabled';
head.innerHTML = '<tr><th>省份</th>' + cols.map(function (c) { return '<th>' + esc(c.label) + '</th>'; }).join('') + '</tr>';
body.innerHTML = model.map(function (row, i) {
var regionCell = row.fixed
? esc(row.region) + '<input type="hidden" name="quote_rows[' + i + '][region]" value="' + esc(row.region) + '" data-q-region>'
: '<input type="text" name="quote_rows[' + i + '][region]" value="' + esc(row.region || '') + '" maxlength="50" placeholder="其他地区" aria-label="自定义地区" data-q-region' + dis + '>';
return '<tr data-q-row="' + i + '"' + (row.fixed ? '' : ' class="is-custom"') + '><td>' + regionCell + '</td>' + cols.map(function (c) {
var slot = slotKey(st, c), v = row.vals[slot];
return '<td><input type="number" inputmode="decimal" step="0.01" min="0" name="' + nameFor(i, c) + '" value="' + esc(v === undefined ? '' : v) + '" data-q-slot="' + esc(slot) + '" aria-label="' + esc((row.region || '自定义') + c.label) + '"' + dis + '></td>';
}).join('') + '</tr>';
}).join('');
if (tools) {
tools.style.gridTemplateColumns = 'repeat(' + Math.min(Math.max(cols.length, 1), 3) + ', minmax(0, 1fr))';
tools.innerHTML = cols.map(function (c, i) {
return '<div class="field"><label for="fill-' + i + '">' + esc(c.label) + '</label><input id="fill-' + i + '" type="number" inputmode="decimal" step="0.01" data-fill-col="' + i + '"' + dis + '></div>';
}).join('') + (editableForm && cols.length ? '<button type="button" class="btn" data-fill-all style="grid-column:1/-1">填充到全部省份</button>' : '');
var fill = tools.querySelector('[data-fill-all]');
if (fill) fill.addEventListener('click', function () {
var filled = 0;
tools.querySelectorAll('[data-fill-col]').forEach(function (src) {
if (src.value === '') return;
filled++;
var slot = slotKey(st, cols[+src.getAttribute('data-fill-col')]);
body.querySelectorAll('tr:not(.is-custom) [data-q-slot]').forEach(function (cell) {
if (cell.getAttribute('data-q-slot') === slot) cell.value = src.value;
});
});
readTable();
if (!filled) window.alert('先在上面填好要统一的价格,再点填充。');
});
}
}
if (quoteSection) {
quoteSection.querySelectorAll('[data-quote-mode], [data-structure-input]').forEach(function (el) {
var typing = el.type === 'text' || el.type === 'number';
el.addEventListener(typing ? 'input' : 'change', function () { readTable(); render(); });
});
render();
}
form.addEventListener('submit', function (event) {
var submitter = event.submitter;
if (totalSize() > MAX_TOTAL) {
event.preventDefault();
window.alert('本次上传的图片总共超过 11MB,请先选几张点「暂存」,再继续上传剩下的。');
return;
}
if (submitter && submitter.value === 'submit' && !window.confirm('提交后需等待我方审核,审核期间不能修改。确认提交?')) {
event.preventDefault();
return;
}
if (submitter) {
var hidden = document.createElement('input');
hidden.type = 'hidden'; hidden.name = 'action'; hidden.value = submitter.value;
form.appendChild(hidden);
}
form.querySelectorAll('[data-submit-button]').forEach(function (button) { button.disabled = true; });
});
})();
</script>
</body>
</html>