快递应付 → 快递报价 下新增「快递入驻」页:生成入驻二维码发给快递方,
对方免登录在手机上填写主体、联系人、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
328 lines
15 KiB
PHP
328 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Support;
|
|
|
|
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;
|
|
|
|
final class CourierQuoteChangeService
|
|
{
|
|
public function __construct(private readonly QuoteManagementSnapshot $snapshot)
|
|
{
|
|
}
|
|
|
|
public function create(array $data, int $userId): CourierQuoteChange
|
|
{
|
|
$source = $this->findSourceQuote($data['source_quote_id']);
|
|
|
|
return DB::transaction(function () use ($data, $userId, $source): CourierQuoteChange {
|
|
$retrospective = $this->isRetrospectiveDate($data['effective_date']);
|
|
$change = CourierQuoteChange::create([
|
|
'company_id' => company_id(),
|
|
'number' => $this->nextChangeNumber(),
|
|
'source_quote_id' => $source['id'],
|
|
'source_sheet_name' => $source['sheet_name'],
|
|
'carrier_name' => $source['company_name'],
|
|
'quote_type' => $source['quote_type'],
|
|
'change_type' => $data['change_type'],
|
|
'status' => CourierQuoteChange::STATUS_DRAFT,
|
|
'effective_date' => $data['effective_date'],
|
|
'is_retrospective' => $retrospective,
|
|
'recalculation_status' => $retrospective
|
|
? CourierQuoteChange::RECALCULATION_PENDING
|
|
: CourierQuoteChange::RECALCULATION_NOT_REQUIRED,
|
|
'recalculation_note' => null,
|
|
'summary' => $data['summary'],
|
|
'created_by' => $userId,
|
|
]);
|
|
|
|
$this->replaceItems($change, $data['items'], $source);
|
|
$this->recordEvent($change, 'created', null, CourierQuoteChange::STATUS_DRAFT, '创建报价变更草稿。', $userId, $this->changeDetails($change));
|
|
|
|
return $change->load('items', 'events');
|
|
});
|
|
}
|
|
|
|
public function update(CourierQuoteChange $change, array $data, int $userId): CourierQuoteChange
|
|
{
|
|
$this->ensureEditableBy($change, $userId);
|
|
$source = $this->findSourceQuote($change->source_quote_id);
|
|
|
|
return DB::transaction(function () use ($change, $data, $userId, $source): CourierQuoteChange {
|
|
$from = $change->status;
|
|
$retrospective = $this->isRetrospectiveDate($data['effective_date']);
|
|
$change->update([
|
|
'change_type' => $data['change_type'],
|
|
'effective_date' => $data['effective_date'],
|
|
'is_retrospective' => $retrospective,
|
|
'recalculation_status' => $retrospective
|
|
? CourierQuoteChange::RECALCULATION_PENDING
|
|
: CourierQuoteChange::RECALCULATION_NOT_REQUIRED,
|
|
'recalculation_note' => null,
|
|
'summary' => $data['summary'],
|
|
'status' => CourierQuoteChange::STATUS_DRAFT,
|
|
]);
|
|
$this->replaceItems($change, $data['items'], $source);
|
|
$this->recordEvent($change, 'saved', $from, CourierQuoteChange::STATUS_DRAFT, '保存报价变更草稿。', $userId, $this->changeDetails($change));
|
|
|
|
return $change->fresh(['items', 'events']);
|
|
});
|
|
}
|
|
|
|
public function submit(CourierQuoteChange $change, int $userId): CourierQuoteChange
|
|
{
|
|
$this->ensureEditableBy($change, $userId);
|
|
|
|
if ($change->items()->count() === 0) {
|
|
throw ValidationException::withMessages(['items' => '至少需要一条发生变化的报价明细。']);
|
|
}
|
|
|
|
return DB::transaction(function () use ($change, $userId): CourierQuoteChange {
|
|
$from = $change->status;
|
|
$retrospective = $this->isRetrospectiveDate($change->effective_date);
|
|
$change->update([
|
|
'status' => CourierQuoteChange::STATUS_PENDING_REVIEW,
|
|
'is_retrospective' => $retrospective,
|
|
'recalculation_status' => $retrospective
|
|
? CourierQuoteChange::RECALCULATION_PENDING
|
|
: CourierQuoteChange::RECALCULATION_NOT_REQUIRED,
|
|
'recalculation_note' => null,
|
|
'submitted_by' => $userId,
|
|
'submitted_at' => now(),
|
|
]);
|
|
$this->recordEvent($change, 'submitted', $from, CourierQuoteChange::STATUS_PENDING_REVIEW, '提交报价变更审核。', $userId, $this->changeDetails($change));
|
|
|
|
return $change->fresh(['items', 'events']);
|
|
});
|
|
}
|
|
|
|
public function returnForChanges(CourierQuoteChange $change, string $note, int $userId): CourierQuoteChange
|
|
{
|
|
if ($change->status !== CourierQuoteChange::STATUS_PENDING_REVIEW) {
|
|
throw ValidationException::withMessages(['status' => '只有待审核的变更单可以退回。']);
|
|
}
|
|
|
|
return DB::transaction(function () use ($change, $note, $userId): CourierQuoteChange {
|
|
$change->update(['status' => CourierQuoteChange::STATUS_RETURNED]);
|
|
$this->recordEvent($change, 'returned', CourierQuoteChange::STATUS_PENDING_REVIEW, CourierQuoteChange::STATUS_RETURNED, $note, $userId);
|
|
|
|
return $change->fresh(['items', 'events']);
|
|
});
|
|
}
|
|
|
|
public function approve(CourierQuoteChange $change, int $userId): CourierQuoteChange
|
|
{
|
|
if ($change->status !== CourierQuoteChange::STATUS_PENDING_REVIEW) {
|
|
throw ValidationException::withMessages(['status' => '只有待审核的变更单可以审核通过。']);
|
|
}
|
|
|
|
// 测试环境可用 COURIER_ONBOARDING_ALLOW_SAME_REVIEWER=true 放开(一人走完流程),正式环境保持发起人不能自审。
|
|
if ($change->created_by === $userId && ! config('courier_onboarding.allow_same_reviewer')) {
|
|
throw ValidationException::withMessages(['approval' => '发起人不能审核自己提交的报价变更。']);
|
|
}
|
|
|
|
$source = $this->findSourceQuote($change->source_quote_id);
|
|
|
|
return DB::transaction(function () use ($change, $source, $userId): CourierQuoteChange {
|
|
$values = $source['values'];
|
|
$formulas = $source['formulas'];
|
|
|
|
// 后台变价 / 重新报价:变价单里已经是完整的新报价矩阵。
|
|
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} 已不存在,请退回重新核对。"]);
|
|
}
|
|
|
|
$values[$item->source_row][$item->source_column] = $item->new_amount;
|
|
}
|
|
|
|
$importedVersionCount = collect($this->snapshot->load()['quotes'] ?? [])
|
|
->whereNull('source_change_id')
|
|
->count();
|
|
$version = CourierQuoteVersion::create([
|
|
'company_id' => company_id(),
|
|
'version_code' => 'PENDING-' . $change->id,
|
|
'source_quote_id' => $source['id'],
|
|
'source_change_id' => $change->id,
|
|
'sheet_name' => $source['sheet_name'],
|
|
'company_name' => $source['company_name'],
|
|
'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($formulas, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
|
'status' => 'approved',
|
|
'signature_status' => '未签署',
|
|
'created_by' => $change->created_by,
|
|
'approved_by' => $userId,
|
|
]);
|
|
$version->update([
|
|
'version_code' => sprintf('QB-%03d', $importedVersionCount + $version->id),
|
|
]);
|
|
|
|
$retrospective = $this->isRetrospectiveDate($change->effective_date);
|
|
$recalculationNote = $retrospective ? $this->retrospectiveBlockReason() : null;
|
|
$change->update([
|
|
'status' => CourierQuoteChange::STATUS_APPROVED,
|
|
'is_retrospective' => $retrospective,
|
|
'recalculation_status' => $retrospective
|
|
? CourierQuoteChange::RECALCULATION_BLOCKED
|
|
: CourierQuoteChange::RECALCULATION_NOT_REQUIRED,
|
|
'recalculation_note' => $recalculationNote,
|
|
'approved_by' => $userId,
|
|
'approved_at' => now(),
|
|
'new_quote_version_id' => $version->id,
|
|
]);
|
|
$eventNote = "审核通过并生成新版本 {$version->version_code}。";
|
|
|
|
if ($retrospective) {
|
|
$eventNote .= ' 该报价为历史生效;补差明细尚未生成,原运费保持不变。';
|
|
}
|
|
|
|
$this->recordEvent($change, 'approved', CourierQuoteChange::STATUS_PENDING_REVIEW, CourierQuoteChange::STATUS_APPROVED, $eventNote, $userId, [
|
|
...$this->changeDetails($change),
|
|
'version_code' => $version->version_code,
|
|
]);
|
|
|
|
return $change->fresh(['items', 'events', 'version']);
|
|
});
|
|
}
|
|
|
|
public function findSourceQuote(string $id): array
|
|
{
|
|
$quote = collect($this->snapshot->load()['quotes'] ?? [])->firstWhere('id', $id);
|
|
|
|
if (! is_array($quote)) {
|
|
throw ValidationException::withMessages(['source_quote_id' => '选择的原报价版本不存在或已不可用。']);
|
|
}
|
|
|
|
return $quote;
|
|
}
|
|
|
|
private function replaceItems(CourierQuoteChange $change, array $items, array $source): void
|
|
{
|
|
$change->items()->withTrashed()->forceDelete();
|
|
|
|
foreach (array_values($items) as $position => $item) {
|
|
$row = (int) $item['source_row'];
|
|
$column = (int) $item['source_column'];
|
|
$sourceValue = $source['values'][$row][$column] ?? null;
|
|
$formula = $source['formulas'][$row][$column] ?? null;
|
|
|
|
if (is_string($formula) && trim($formula) !== '') {
|
|
throw ValidationException::withMessages(["items.{$position}.source_cell" => "{$item['source_cell']} 是公式单元格,不能直接改价。"]);
|
|
}
|
|
|
|
if (! is_numeric($sourceValue)) {
|
|
throw ValidationException::withMessages(["items.{$position}.source_cell" => "{$item['source_cell']} 不是可调整的数值单元格。"]);
|
|
}
|
|
|
|
if (abs((float) $sourceValue - (float) $item['original_amount']) > 0.00005) {
|
|
throw ValidationException::withMessages(["items.{$position}.original_amount" => "{$item['source_cell']} 的原价格已经变化,请重新载入后再保存。"]);
|
|
}
|
|
|
|
if ((float) $item['new_amount'] < 0) {
|
|
throw ValidationException::withMessages(["items.{$position}.new_amount" => '新价格不能小于 0。']);
|
|
}
|
|
|
|
if (round((float) $item['new_amount'], 4) === round((float) $item['original_amount'], 4)) {
|
|
continue;
|
|
}
|
|
|
|
$change->items()->create([
|
|
'company_id' => company_id(),
|
|
'position' => $position,
|
|
'source_cell' => strtoupper($item['source_cell']),
|
|
'source_row' => $row,
|
|
'source_column' => $column,
|
|
'region' => $item['region'] ?? null,
|
|
'weight_band' => $item['weight_band'] ?? null,
|
|
'charge_item' => $item['charge_item'] ?? null,
|
|
'original_amount' => number_format((float) $item['original_amount'], 4, '.', ''),
|
|
'new_amount' => number_format((float) $item['new_amount'], 4, '.', ''),
|
|
'unit' => $item['unit'] ?? null,
|
|
'note' => $item['note'] ?? null,
|
|
]);
|
|
}
|
|
|
|
if ($change->items()->count() === 0) {
|
|
throw ValidationException::withMessages(['items' => '至少需要一条原价格与新价格不同的变价明细。']);
|
|
}
|
|
}
|
|
|
|
private function ensureEditableBy(CourierQuoteChange $change, int $userId): void
|
|
{
|
|
if (! $change->isEditable()) {
|
|
throw ValidationException::withMessages(['status' => '当前状态不允许继续修改。']);
|
|
}
|
|
|
|
if ($change->created_by !== $userId) {
|
|
abort(403, '只有草稿发起人可以修改或提交该变更单。');
|
|
}
|
|
}
|
|
|
|
private function recordEvent(CourierQuoteChange $change, string $type, ?string $from, ?string $to, string $note, int $userId, array $details = []): void
|
|
{
|
|
CourierQuoteChangeEvent::create([
|
|
'company_id' => company_id(),
|
|
'quote_change_id' => $change->id,
|
|
'event_type' => $type,
|
|
'from_status' => $from,
|
|
'to_status' => $to,
|
|
'note' => $note,
|
|
'details_json' => $details === [] ? null : json_encode($details, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
|
'created_by' => $userId,
|
|
]);
|
|
}
|
|
|
|
private function changeDetails(CourierQuoteChange $change): array
|
|
{
|
|
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,
|
|
'summary' => $change->summary,
|
|
'items' => $change->items()->get()->map(static fn ($item): array => [
|
|
'source_cell' => $item->source_cell,
|
|
'original_amount' => $item->original_amount,
|
|
'new_amount' => $item->new_amount,
|
|
'note' => $item->note,
|
|
])->all(),
|
|
];
|
|
}
|
|
|
|
private function nextChangeNumber(): string
|
|
{
|
|
$prefix = 'BG-' . now()->format('Ymd') . '-';
|
|
$last = CourierQuoteChange::withTrashed()->where('number', 'like', $prefix . '%')->lockForUpdate()->max('number');
|
|
$sequence = $last ? ((int) substr($last, -3)) + 1 : 1;
|
|
|
|
return $prefix . str_pad((string) $sequence, 3, '0', STR_PAD_LEFT);
|
|
}
|
|
|
|
private function isRetrospectiveDate(string|Carbon $effectiveDate): bool
|
|
{
|
|
return Carbon::parse($effectiveDate)->startOfDay()->lt(today());
|
|
}
|
|
|
|
private function retrospectiveBlockReason(): string
|
|
{
|
|
return '未生成补差明细:当前报价未提供可执行的运费计算方式,历史运费也未记录所用报价版本。补差前需建立逐票原报价版本关联和可验证的报价计算规则;原运费及原始 Excel 保持不变。';
|
|
}
|
|
|
|
}
|