158 lines
6.9 KiB
PHP
158 lines
6.9 KiB
PHP
<?php
|
||
|
||
namespace App\Support;
|
||
|
||
use App\Models\Business\CourierQuoteVersion;
|
||
use DateTimeInterface;
|
||
use Illuminate\Support\Collection;
|
||
|
||
/**
|
||
* 按「快递编码 + 发货日期」找到当天生效的报价版本,用结构化计费规则算一票的运费。
|
||
*
|
||
* 计费重量 = max(实重, 体积重, 起算重量) 再按取整方式处理;超过限重不计算。
|
||
*
|
||
* 生效版本的判断与报价管理页一致(CourierRequoteService::resolveAt:未作废、已审核或已签署归档、
|
||
* 执行期间覆盖发货日期,短期变价优先)。没有结构化规则的版本(按体积、按条码等特殊计费)返回原因,交人工核算。
|
||
*/
|
||
class CourierQuoteFreightCalculator
|
||
{
|
||
/** @var array<string,Collection<int,CourierQuoteVersion>> */
|
||
private array $versions = [];
|
||
|
||
/**
|
||
* @param string $courierExpcode 快递编码;没有编码的存量报价可传快递名称(如「沐野竹木顺丰」)
|
||
* @return array{
|
||
* version_code:?string,company_name:?string,region:?string,gross_weight:float,volume_weight:?float,
|
||
* billable_weight:?float,base_amount:?float,surcharge:float,amount:?float,formula:?string,reason:?string
|
||
* }
|
||
*/
|
||
public function calculate(
|
||
string $courierExpcode,
|
||
DateTimeInterface $shipDate,
|
||
string $province,
|
||
float $grossWeight,
|
||
?float $length = null,
|
||
?float $width = null,
|
||
?float $height = null,
|
||
?string $city = null,
|
||
?string $quoteType = null,
|
||
): array {
|
||
$result = [
|
||
'version_code' => null, 'company_name' => null, 'region' => null, 'gross_weight' => $grossWeight,
|
||
'volume_weight' => null, 'billable_weight' => null, 'base_amount' => null, 'surcharge' => 0.0,
|
||
'amount' => null, 'formula' => null, 'reason' => null,
|
||
];
|
||
if (! is_finite($grossWeight) || $grossWeight < 0
|
||
|| collect([$length, $width, $height])->contains(static fn ($v) => $v !== null && (! is_finite($v) || $v < 0))) {
|
||
return ['reason' => '重量和尺寸必须是有效非负数字'] + $result;
|
||
}
|
||
|
||
$version = $this->versionAt(trim($courierExpcode), $shipDate, $quoteType);
|
||
if ($version === null) {
|
||
return ['reason' => '发货日期没有生效的报价'] + $result;
|
||
}
|
||
$result['version_code'] = $version->version_code;
|
||
$result['company_name'] = $version->company_name;
|
||
if (! $version->hasBillingRules()) {
|
||
return ['reason' => '该报价为特殊计费或尚未设置计费规则,需人工核算'] + $result;
|
||
}
|
||
|
||
$structure = (array) $version->billing_structure;
|
||
$row = $this->matchRow((array) $version->billing_rows, $province, $city);
|
||
if ($row === null) {
|
||
return ['reason' => '报价未覆盖目的地「' . trim($province . ' ' . $city) . '」或该地区不发'] + $result;
|
||
}
|
||
$result['region'] = (string) $row['region'];
|
||
|
||
$divisor = (float) ($structure['volumetric_divisor'] ?? 0);
|
||
$volume = $divisor > 0 && $length > 0 && $width > 0 && $height > 0 ? round($length * $width * $height / $divisor, 4) : null;
|
||
$settlement = max($grossWeight, (float) $volume);
|
||
$result['volume_weight'] = $volume;
|
||
if ($settlement <= 0) {
|
||
return ['reason' => '缺少重量,无法计算运费'] + $result;
|
||
}
|
||
|
||
$limit = (float) ($structure['weight_limit'] ?? 0);
|
||
if ($limit > 0 && $settlement > $limit + 1e-9) {
|
||
return ['reason' => '超出限重 ' . CourierQuoteStructure::kg($limit) . 'kg'] + $result;
|
||
}
|
||
|
||
// 起算重量:不足按起算重量计(如菏泽顺帆顺丰不足 5kg 按 5kg)
|
||
$minimum = (float) ($structure['min_weight'] ?? 0);
|
||
$billable = CourierQuoteStructure::roundWeight(max($settlement, $minimum), (string) ($structure['rounding'] ?? 'none'));
|
||
$result['billable_weight'] = $billable;
|
||
$freight = CourierQuoteStructure::freight($structure, $row, $billable);
|
||
if ($freight['amount'] === null) {
|
||
return ['reason' => $freight['reason']] + $result;
|
||
}
|
||
|
||
$surcharge = (float) ($structure['per_ticket_surcharge'] ?? 0);
|
||
$result['base_amount'] = round($freight['amount'], 2);
|
||
$result['surcharge'] = $surcharge;
|
||
$result['amount'] = round($freight['amount'] + $surcharge, 2);
|
||
$result['formula'] = $freight['formula'] . ($surcharge > 0 ? '+每票' . CourierQuoteStructure::kg($surcharge) : '');
|
||
|
||
return $result;
|
||
}
|
||
|
||
/** 省份名统一:去掉省/市/自治区后缀,黑龙→黑龙江、内蒙→内蒙古。 */
|
||
public static function regionKey(string $name): string
|
||
{
|
||
$text = (string) preg_replace('/\s+/u', '', $name);
|
||
$text = (string) preg_replace('/(维吾尔自治区|壮族自治区|回族自治区|自治区|特别行政区|省|市)$/u', '', $text);
|
||
|
||
return match ($text) {
|
||
'黑龙' => '黑龙江',
|
||
'内蒙' => '内蒙古',
|
||
default => $text,
|
||
};
|
||
}
|
||
|
||
private function versionAt(string $courierExpcode, DateTimeInterface $date, ?string $quoteType): ?CourierQuoteVersion
|
||
{
|
||
if ($courierExpcode === '') {
|
||
return null;
|
||
}
|
||
// 先按快递编码;部分存量版本没有编码,按快递名称(与报价管理页的分组口径一致)
|
||
$cacheKey = company_id() . ':' . $courierExpcode;
|
||
$versions = $this->versions[$cacheKey] ??= CourierQuoteVersion::query()
|
||
->with(['signature', 'change'])
|
||
->where(static fn ($query) => $query->where('courier_expcode', $courierExpcode)
|
||
->orWhere(static fn ($q) => $q->where(static fn ($e) => $e->whereNull('courier_expcode')->orWhere('courier_expcode', ''))
|
||
->where('company_name', $courierExpcode)))
|
||
->get();
|
||
|
||
return CourierRequoteService::resolveAt(
|
||
$quoteType === null ? $versions : $versions->where('quote_type', $quoteType),
|
||
$date,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 先按城市匹配单列的城市行(如「深圳(广东)」),再按省份匹配。
|
||
*
|
||
* @param list<array<string,mixed>> $rows
|
||
* @return array<string,mixed>|null
|
||
*/
|
||
private function matchRow(array $rows, string $province, ?string $city): ?array
|
||
{
|
||
$cityKey = self::regionKey((string) $city);
|
||
if ($cityKey !== '') {
|
||
foreach ($rows as $row) {
|
||
if (is_array($row) && preg_match('/^(.+?)[((](.+?)[))]$/u', trim((string) ($row['region'] ?? '')), $parts)
|
||
&& self::regionKey($parts[1]) === $cityKey && self::regionKey($parts[2]) === self::regionKey($province)) {
|
||
return $row;
|
||
}
|
||
}
|
||
}
|
||
$key = self::regionKey($province);
|
||
foreach ($rows as $row) {
|
||
if (is_array($row) && self::regionKey((string) ($row['region'] ?? '')) === $key) {
|
||
return $row;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
}
|