437 lines
19 KiB
PHP
437 lines
19 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', 'change.stockEntry'])->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 && blank($version->courier_expcode) && blank($version->billing_rules)
|
||
&& ! $version->change?->isStockQuote() ? 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 billingSummary(CourierQuoteVersion $version): string
|
||
{
|
||
$structure = (array) $version->billing_structure;
|
||
$kg = static fn ($value): string => CourierQuoteStructure::kg((float) $value);
|
||
$parts = array_filter([
|
||
CourierQuoteStructure::describe($structure),
|
||
($structure['rounding'] ?? 'none') !== 'none' ? (CourierQuoteStructure::ROUNDING[$structure['rounding']] ?? null) : null,
|
||
filled($structure['min_weight'] ?? null) ? '不足' . $kg($structure['min_weight']) . 'kg按' . $kg($structure['min_weight']) . 'kg计' : null,
|
||
filled($structure['volumetric_divisor'] ?? null) ? '计泡 ' . $structure['volumetric_divisor'] : null,
|
||
filled($structure['weight_limit'] ?? null) ? '限重 ' . $kg($structure['weight_limit']) . 'kg' : null,
|
||
filled($structure['per_ticket_surcharge'] ?? null) ? '每票加收 ' . $kg($structure['per_ticket_surcharge']) . ' 元' : null,
|
||
]);
|
||
|
||
return implode(';', $parts) . '(' . count((array) $version->billing_rows) . ' 个地区)';
|
||
}
|
||
|
||
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,
|
||
'courier_expcode' => $version->courier_expcode,
|
||
'billing_rules' => $version->billing_rules,
|
||
'billing_summary' => $version->hasBillingRules() ? $this->billingSummary($version) : null,
|
||
'carrier' => $version->carrier_name,
|
||
'quote_type' => $version->quote_type,
|
||
'effective_date' => $version->effective_date?->toDateString(),
|
||
'end_date' => $version->end_date?->toDateString(),
|
||
'period_label' => $version->end_date !== null ? $version->periodLabel() : null,
|
||
'group_key' => (filled($version->courier_expcode) ? 'code:' . $version->courier_expcode : 'name:' . $version->company_name) . ':' . $version->quote_type,
|
||
'stock_entry_id' => $version->change?->isStockQuote() ? data_get($version->change->quote_payload, 'stock_quote_entry_id') : null,
|
||
'eligible' => !$version->isVoided() && $this->isExecutable($version),
|
||
'is_stock' => (bool) $version->change?->isStockQuote(),
|
||
'period_end_url' => $version->isApprovedStockQuote() && in_array(\App\Utilities\SystemAccess::roleName(user()), [\App\Utilities\SystemAccess::ADMIN, \App\Utilities\SystemAccess::SUPER_ADMIN], true)
|
||
? route('business.courier-stock-quotes.period-end', data_get($version->change->quote_payload, 'stock_quote_entry_id')) : 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->signatureLabel(),
|
||
'values' => $values,
|
||
'formulas' => $formulas,
|
||
'source_change_id' => $version->source_change_id,
|
||
'signed_files' => $this->signedFiles($version),
|
||
'stock_originals' => $this->stockOriginals($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
|
||
{
|
||
if ($version->signature_status === '历史签署资料已归档' && ($entry = $version->change?->stockEntry)) {
|
||
foreach ($entry->files as $index => $file) {
|
||
if ($file['kind'] === 'signed') {
|
||
return ['signed' => route('business.courier-stock-quotes.download', [$entry, $index])];
|
||
}
|
||
}
|
||
}
|
||
$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 stockOriginals(CourierQuoteVersion $version): array
|
||
{
|
||
if (!$version->change?->isStockQuote() || !($entry = $version->change->stockEntry)) return [];
|
||
$files = [];
|
||
foreach ($entry->files as $index => $file) {
|
||
if (($file['kind'] ?? '') !== 'signed') continue;
|
||
// 早期手动上传未记录 MIME;入口按扩展名提供,响应仍按实际 MIME 白名单校验。
|
||
$previewable = isset($file['mime_type'])
|
||
? in_array($file['mime_type'], ['application/pdf', 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'], true)
|
||
: in_array(strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)), ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'], true);
|
||
$files[] = [
|
||
'name' => $file['name'], 'previewable' => $previewable,
|
||
'url' => route('business.courier-stock-quotes.download', [$entry, $index, 'preview' => $previewable ? 1 : 0]),
|
||
];
|
||
}
|
||
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 ($version->end_date?->lt(today()) && $this->isExecutable($version)) return '已到期';
|
||
|
||
if (! $this->isExecutable($version)) {
|
||
return '待生效(待签章)';
|
||
}
|
||
|
||
if ($version->effective_date?->isFuture()) {
|
||
return $version->isTemporary() ? '待生效(临时)' : '待生效';
|
||
}
|
||
|
||
return $version->isTemporary() ? '生效中(临时)' : '生效中';
|
||
}
|
||
|
||
/** 存量报价审核生效;其他报价仍要求回签归档。 */
|
||
private function isExecutable(CourierQuoteVersion $version): bool
|
||
{
|
||
return $version->isApprovedStockQuote() || in_array($version->signature_status, ['已签署并归档', '历史签署资料已归档'], true)
|
||
|| $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(static fn (CourierQuoteVersion $version): string => filled($version->courier_expcode)
|
||
? 'code:' . $version->courier_expcode . ':' . $version->quote_type
|
||
: ($version->change?->isStockQuote() || filled($version->billing_rules)
|
||
? 'stock-name:' . $version->company_name . ':' . $version->quote_type
|
||
: 'name:' . $version->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->isExecutable($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->end_date?->lt($today)) {
|
||
$result[$version->id] = ['status' => '已到期', 'status_note' => ($version->isTemporary() ? '临时价 ' : '执行期间 ') . $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,
|
||
],
|
||
];
|
||
}
|
||
}
|