快递应付 → 快递报价 下新增「快递入驻」页:生成入驻二维码发给快递方,
对方免登录在手机上填写主体、联系人、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
379 lines
15 KiB
PHP
379 lines
15 KiB
PHP
<?php
|
||
|
||
namespace App\Support;
|
||
|
||
use App\Models\Business\CourierQuoteSignature;
|
||
use App\Models\Business\CourierQuoteVersion;
|
||
use Illuminate\Support\Facades\Schema;
|
||
use JsonException;
|
||
|
||
final class QuoteManagementSnapshot
|
||
{
|
||
public const RELATIVE_PATH = 'app/private/quote-management/quotes.json';
|
||
|
||
public function __construct(private readonly ?string $path = null)
|
||
{
|
||
}
|
||
|
||
public function load(): array
|
||
{
|
||
$path = $this->path ?? storage_path(self::RELATIVE_PATH);
|
||
$versions = $this->versionQuotes();
|
||
$payload = null;
|
||
$failure = null;
|
||
|
||
if (! is_file($path)) {
|
||
$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', '本机报价快照结构不完整,请重新导入源文件。');
|
||
}
|
||
}
|
||
|
||
// 没有 Excel 快照时,系统内生成的报价版本(变价、快递入驻)照常显示。
|
||
if ($failure !== null && $versions === []) {
|
||
return $failure;
|
||
}
|
||
|
||
$quotes = [];
|
||
|
||
foreach (array_values($failure === null ? $payload['sheets'] : []) as $position => $sheet) {
|
||
if (! is_array($sheet) || trim((string) ($sheet['name'] ?? '')) === '') {
|
||
continue;
|
||
}
|
||
|
||
$quotes[] = $this->normalizeSheet($sheet, $position);
|
||
}
|
||
|
||
array_push($quotes, ...$versions);
|
||
|
||
return [
|
||
'available' => true,
|
||
'state' => 'ready',
|
||
'message' => null,
|
||
'source_file' => basename(str_replace('\\', '/', (string) (($payload['sourcePath'] ?? null) ?: '报价单合并.xlsx'))),
|
||
'quotes' => $quotes,
|
||
'summary' => [
|
||
'quote_count' => count($quotes),
|
||
'carrier_count' => count(array_unique(array_column($quotes, 'carrier'))),
|
||
'dated_count' => count(array_filter($quotes, fn (array $quote) => $quote['effective_date'] !== null)),
|
||
'formula_count' => array_sum(array_column($quotes, 'formula_count')),
|
||
],
|
||
];
|
||
}
|
||
|
||
/** @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']);
|
||
$values = $this->normalizeMatrix($sheet['values'] ?? []);
|
||
$formulas = $this->normalizeMatrix($sheet['formulas'] ?? []);
|
||
$columnCount = max(array_map('count', $values ?: [[]]));
|
||
|
||
return [
|
||
'id' => sprintf('QB-%03d', $position + 1),
|
||
'sheet_index' => (int) ($sheet['index'] ?? $position),
|
||
'sheet_name' => $name,
|
||
'company_name' => $this->inferCompanyName($name),
|
||
'carrier' => $this->inferCarrier($name),
|
||
'quote_type' => $this->inferQuoteType($name),
|
||
'effective_date' => $this->extractEffectiveDate($values),
|
||
'source_range' => trim((string) ($sheet['address'] ?? '')),
|
||
'row_count' => count($values),
|
||
'column_count' => $columnCount,
|
||
'formula_count' => $this->formulaCount($formulas),
|
||
'status' => $this->extractEffectiveDate($values) === null ? '未标执行日期' : '已标执行日期',
|
||
'signature_status' => 'Excel 未提供',
|
||
'signed_files' => [],
|
||
'signed_at' => null,
|
||
'values' => $values,
|
||
'formulas' => $formulas,
|
||
];
|
||
}
|
||
|
||
private function inferCompanyName(string $name): string
|
||
{
|
||
$companyName = trim((string) preg_replace('/(?:报价表|报价)$/u', '', $name));
|
||
|
||
return $companyName !== '' ? $companyName : $name;
|
||
}
|
||
|
||
private function normalizeVersion(CourierQuoteVersion $version): array
|
||
{
|
||
$values = $this->decodeMatrix($version->values_json);
|
||
$formulas = $this->decodeMatrix($version->formulas_json);
|
||
$columnCount = max(array_map('count', $values ?: [[]]));
|
||
|
||
return [
|
||
'id' => $version->version_code,
|
||
'sheet_index' => null,
|
||
'sheet_name' => $version->sheet_name,
|
||
'company_name' => $version->company_name,
|
||
'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,
|
||
'formula_count' => $this->formulaCount($formulas),
|
||
'status' => $this->versionLifecycleStatus($version),
|
||
'signature_status' => $version->signature_status,
|
||
'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) === '') {
|
||
return [];
|
||
}
|
||
|
||
try {
|
||
return $this->normalizeMatrix(json_decode($json, true, 512, JSON_THROW_ON_ERROR));
|
||
} catch (JsonException) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
private function versionLifecycleStatus(CourierQuoteVersion $version): string
|
||
{
|
||
if ($version->isVoided()) {
|
||
return '已作废';
|
||
}
|
||
|
||
if (! $this->isSigned($version)) {
|
||
return '待生效(待签章)';
|
||
}
|
||
|
||
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
|
||
{
|
||
if (! is_array($matrix)) {
|
||
return [];
|
||
}
|
||
|
||
return array_map(function ($row) {
|
||
if (! is_array($row)) {
|
||
return [$this->normalizeCell($row)];
|
||
}
|
||
|
||
return array_map(fn ($cell) => $this->normalizeCell($cell), array_values($row));
|
||
}, array_values($matrix));
|
||
}
|
||
|
||
private function normalizeCell(mixed $cell): string|int|float|bool|null
|
||
{
|
||
if ($cell === null || is_string($cell) || is_int($cell) || is_float($cell) || is_bool($cell)) {
|
||
return $cell;
|
||
}
|
||
|
||
return (string) $cell;
|
||
}
|
||
|
||
private function inferCarrier(string $name): string
|
||
{
|
||
foreach ([
|
||
'中通快运', '顺心捷达', '安能快运', '京广', 'EMS', '中通', '圆通', '德邦',
|
||
'申通', '极兔', '安能', '邮政', '韵达', '顺丰',
|
||
] as $carrier) {
|
||
if (str_contains($name, $carrier)) {
|
||
return $carrier;
|
||
}
|
||
}
|
||
|
||
return '其他快递';
|
||
}
|
||
|
||
private function inferQuoteType(string $name): string
|
||
{
|
||
foreach ([
|
||
'其他配件' => '其他配件报价',
|
||
'配件' => '配件报价',
|
||
'超长' => '超长报价',
|
||
'上楼' => '上楼报价',
|
||
'大件' => '大件报价',
|
||
'小件' => '小件报价',
|
||
'其他' => '其他报价',
|
||
'快运' => '快运报价',
|
||
] as $keyword => $type) {
|
||
if (str_contains($name, $keyword)) {
|
||
return $type;
|
||
}
|
||
}
|
||
|
||
return '标准报价';
|
||
}
|
||
|
||
private function extractEffectiveDate(array $values): ?string
|
||
{
|
||
foreach ($values as $row) {
|
||
foreach ($row as $cell) {
|
||
if (! is_string($cell)) {
|
||
continue;
|
||
}
|
||
|
||
if (preg_match('/(20\d{2})[.\/\-年]\s*(\d{1,2})[.\/\-月]\s*(\d{1,2})日?/u', $cell, $match) === 1) {
|
||
return sprintf('%04d-%02d-%02d', (int) $match[1], (int) $match[2], (int) $match[3]);
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private function formulaCount(array $formulas): int
|
||
{
|
||
$count = 0;
|
||
|
||
foreach ($formulas as $row) {
|
||
foreach ($row as $formula) {
|
||
if (is_string($formula) && trim($formula) !== '') {
|
||
$count++;
|
||
}
|
||
}
|
||
}
|
||
|
||
return $count;
|
||
}
|
||
|
||
private function emptySnapshot(string $state, string $message): array
|
||
{
|
||
return [
|
||
'available' => false,
|
||
'state' => $state,
|
||
'message' => $message,
|
||
'source_file' => '报价单合并.xlsx',
|
||
'quotes' => [],
|
||
'summary' => [
|
||
'quote_count' => 0,
|
||
'carrier_count' => 0,
|
||
'dated_count' => 0,
|
||
'formula_count' => 0,
|
||
],
|
||
];
|
||
}
|
||
}
|