CaiWuXiTong/app/Support/QuoteManagementSnapshot.php

437 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

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
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,
],
];
}
}