> */ 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> $rows * @return array|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; } }