diff --git a/app/Console/Commands/ImportCourierQuoteBillingRules.php b/app/Console/Commands/ImportCourierQuoteBillingRules.php new file mode 100644 index 00000000..69cf8cf7 --- /dev/null +++ b/app/Console/Commands/ImportCourierQuoteBillingRules.php @@ -0,0 +1,185 @@ +option('company'); + if ($companyId <= 0 || ! Company::query()->where('id', $companyId)->exists()) { + $this->error('请指定有效的 --company。'); + + return self::FAILURE; + } + + $payload = json_decode((string) @file_get_contents((string) $this->argument('file')), true); + $items = is_array($payload) ? ($payload['versions'] ?? null) : null; + if (! is_array($items) || $items === []) { + $this->error('文件不是有效的计费规则 JSON(缺少 versions)。'); + + return self::FAILURE; + } + + $plan = []; + $report = []; + foreach ($items as $item) { + $code = (string) ($item['version_id'] ?? ''); + [$status, $note, $data] = $this->prepare($companyId, $item); + $report[] = [$code, (string) ($item['company_name'] ?? ''), $status, $note]; + if ($data !== null) { + $plan[] = $data; + } + } + + $this->table(['版本', '快递公司', '结果', '说明'], $report); + $counts = array_count_values(array_column($report, 2)); + $summary = collect($counts)->map(fn ($n, $k) => "{$k} {$n}")->implode(','); + + if (isset($counts['错误'])) { + $this->error('存在错误,整批未写入:' . $summary); + + return self::FAILURE; + } + if ($this->option('dry-run')) { + $this->info('校验通过,未写入(dry-run):' . $summary); + + return self::SUCCESS; + } + + DB::transaction(function () use ($plan): void { + foreach ($plan as [$version, $structure, $rows]) { + $version->forceFill(['billing_structure' => $structure, 'billing_rows' => $rows])->save(); + } + }); + $this->info('导入完成:' . $summary); + + return self::SUCCESS; + } + + /** + * @param array $item + * @return array{0:string,1:string,2:?array} + */ + private function prepare(int $companyId, array $item): array + { + $code = (string) ($item['version_id'] ?? ''); + $version = CourierQuoteVersion::query()->where('company_id', $companyId)->where('version_code', $code)->first(); + if ($version === null) { + return ['错误', '系统中找不到该版本号', null]; + } + if (trim((string) $version->company_name) !== trim((string) ($item['company_name'] ?? ''))) { + return ['错误', '快递名称不一致:系统为「' . $version->company_name . '」', null]; + } + if (($item['conversion'] ?? '') === 'manual') { + return ['跳过', '特殊计费(' . ($item['manual_reason'] ?? '按体积/条码') . '),需人工核算', null]; + } + + $source = (array) ($item['structure'] ?? []); + $segments = ($item['conversion'] ?? '') === 'needs_segment_mode'; + $mode = $segments ? CourierQuoteStructure::SEGMENTS : (string) ($source['mode'] ?? ''); + if (! in_array($mode, self::MODES, true)) { + return ['错误', '不支持的计费方式:' . $mode, null]; + } + + $params = (array) ($item['params'] ?? []); + $perTicket = 0.0; + foreach ((array) ($params['surcharges'] ?? []) as $surcharge) { + if (($surcharge['condition'] ?? '') === '每票' && is_numeric($surcharge['amount'] ?? null)) { + $perTicket += (float) $surcharge['amount']; + } + } + $structure = [ + 'mode' => $mode, + 'flat_bands' => $segments ? [] : $this->numbers($source['flat_bands'] ?? []), + 'then_mode' => $segments ? '' : (string) ($source['then_mode'] ?? ''), + 'first_weight' => ! $segments && is_numeric($source['first_weight'] ?? null) ? (float) $source['first_weight'] : null, + 'rate_tiers' => $segments ? [] : $this->numbers($source['rate_tiers'] ?? []), + 'rounding' => in_array($source['rounding'] ?? 'none', array_keys(CourierQuoteStructure::ROUNDING), true) ? (string) ($source['rounding'] ?? 'none') : 'none', + 'volumetric_divisor' => is_numeric($params['volumetric_divisor'] ?? null) ? (int) $params['volumetric_divisor'] : null, + 'weight_limit' => is_numeric($params['weight_limit_kg'] ?? null) ? (float) $params['weight_limit_kg'] : null, + 'min_weight' => is_numeric($source['min_weight'] ?? $params['min_weight_kg'] ?? null) ? (float) ($source['min_weight'] ?? $params['min_weight_kg']) : null, + 'per_ticket_surcharge' => $perTicket > 0 ? $perTicket : null, + 'notes' => array_values(array_map('strval', (array) ($params['notes'] ?? []))), + 'imported_from' => 'stock-quote-billing-import', + ]; + + $rows = []; + foreach ((array) ($segments ? ($item['segments_rows'] ?? []) : ($item['rows'] ?? [])) as $row) { + $region = trim((string) ($row['region'] ?? '')); + if ($region === '') { + return ['错误', '存在没有地区名的行', null]; + } + if ($segments) { + $list = []; + foreach ((array) ($row['segments'] ?? []) as $s) { + $list[] = array_filter([ + 'lo' => (float) ($s['lo'] ?? 0), + 'hi' => is_numeric($s['hi'] ?? null) ? (float) $s['hi'] : null, + 'type' => (string) ($s['type'] ?? ''), + 'price' => $this->number($s['price'] ?? null), + 'base' => $this->number($s['base'] ?? null), + 'rate' => $this->number($s['rate'] ?? null), + 'fw' => $this->number($s['fw'] ?? null), + ], static fn ($v, $k) => $v !== null || $k === 'hi', ARRAY_FILTER_USE_BOTH); + } + $rows[] = ['region' => $region, 'segments' => $list]; + } else { + $rows[] = [ + 'region' => $region, + 'flat' => array_map(fn ($v) => $this->number($v), array_values((array) ($row['flat'] ?? []))), + 'base' => $this->number($row['base'] ?? null), + 'rates' => array_map(fn ($v) => $this->number($v), array_values((array) ($row['rates'] ?? []))), + ]; + } + } + if ($rows === []) { + return ['错误', '没有任何地区价格', null]; + } + + $existing = $version->billing_structure; + $note = CourierQuoteStructure::describe($structure) . ',' . count($rows) . ' 个地区'; + if (is_array($existing) && $existing !== []) { + if ($existing == $structure && $version->billing_rows == $rows) { + return ['跳过', '已导入且内容相同', null]; + } + if (! $this->option('force')) { + return ['错误', '已有不同的计费规则,如需覆盖请加 --force', null]; + } + + return ['覆盖', $note, [$version, $structure, $rows]]; + } + + return ['写入', $note, [$version, $structure, $rows]]; + } + + private function number(mixed $value): ?float + { + return is_numeric($value) ? round((float) $value, 4) : null; + } + + /** @return list */ + private function numbers(mixed $values): array + { + return array_values(array_map('floatval', array_filter((array) $values, 'is_numeric'))); + } +} diff --git a/app/Models/Business/CourierQuoteVersion.php b/app/Models/Business/CourierQuoteVersion.php index 764c3131..e7935fa2 100644 --- a/app/Models/Business/CourierQuoteVersion.php +++ b/app/Models/Business/CourierQuoteVersion.php @@ -14,6 +14,7 @@ class CourierQuoteVersion extends Model 'company_id', 'version_code', 'source_quote_id', 'source_change_id', 'sheet_name', 'company_name', 'carrier_name', 'courier_expcode', 'billing_rules', 'quote_type', 'effective_date', 'end_date', 'source_range', 'values_json', 'formulas_json', 'status', 'signature_status', 'created_by', 'approved_by', 'void_reason', 'voided_at', 'voided_by', + 'billing_structure', 'billing_rows', ]; public const STATUS_APPROVED = 'approved'; @@ -26,6 +27,8 @@ class CourierQuoteVersion extends Model 'effective_date' => 'date', 'end_date' => 'date', 'voided_at' => 'datetime', + 'billing_structure' => 'array', + 'billing_rows' => 'array', ]; /** 短期(临时)变价:有结束日期,到期后回到原来的长期报价。 */ @@ -56,6 +59,14 @@ class CourierQuoteVersion extends Model : $this->effective_date->toDateString() . ' 起'; } + /** 是否已有可用于运费核算的结构化计费规则。 */ + public function hasBillingRules(): bool + { + $mode = $this->billing_structure['mode'] ?? null; + + return is_string($mode) && $mode !== 'other' && is_array($this->billing_rows) && $this->billing_rows !== []; + } + public function isVoided(): bool { return $this->status === self::STATUS_VOIDED; diff --git a/app/Support/CourierQuoteFreightCalculator.php b/app/Support/CourierQuoteFreightCalculator.php new file mode 100644 index 00000000..9cc71ce9 --- /dev/null +++ b/app/Support/CourierQuoteFreightCalculator.php @@ -0,0 +1,151 @@ +> */ + 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, + ]; + + $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; + } + // 先按快递编码;部分存量版本没有编码,按快递名称(与报价管理页的分组口径一致) + $versions = $this->versions[$courierExpcode] ??= 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) && str_starts_with((string) ($row['region'] ?? ''), $cityKey . '(')) { + return $row; + } + } + } + $key = self::regionKey($province); + foreach ($rows as $row) { + if (is_array($row) && self::regionKey((string) ($row['region'] ?? '')) === $key) { + return $row; + } + } + + return null; + } +} diff --git a/app/Support/CourierQuoteStructure.php b/app/Support/CourierQuoteStructure.php index 3ae31c6d..7c9b81ae 100644 --- a/app/Support/CourierQuoteStructure.php +++ b/app/Support/CourierQuoteStructure.php @@ -40,6 +40,13 @@ final class CourierQuoteStructure 'round' => '四舍五入(如 4.4kg 按 4kg,4.5kg 按 5kg)', ]; + /** + * 分段计费:不放进 MODES(入驻表单不提供),只用于存量报价导入。 + * 每个省份行为 {region, segments:[{lo, hi, type: flat|waybill|first_add, price|base, rate, fw}]}, + * 重量落在 (lo, hi](第一段含 0)就按该段计算;hi 为 null 表示不封顶。 + */ + public const SEGMENTS = 'segments'; + public const MAX_BANDS = 12; public const MAX_TIERS = 6; @@ -272,10 +279,114 @@ final class CourierQuoteStructure ? "首重{$fw}kg价+(重量-{$fw})*续重" : '面单+重量*费率') . $tierNote, 'other' => '特殊计费,见附件报价表', + self::SEGMENTS => '分段计费:每个重量段单独设一口价、面单+费率或首重+续重(可表示累进计费)', default => '面单+重量*费率' . $tierNote, }; } + /** + * 按结构化报价计算一票的基础运费(不含计泡、限重、附加费,这些由调用方先处理)。 + * + * 与 describe() 口径一致:费率分段按「总重量所在区间」取费率,区间上限含本数; + * 一口价/费率为空表示该重量段不发。$weight 应为已取整的计费重量。 + * + * @param array $structure + * @param array $row + * @return array{amount:?float,formula:?string,reason:?string} + */ + public static function freight(array $structure, array $row, float $weight): array + { + $mode = (string) ($structure['mode'] ?? 'waybill_rate'); + $none = static fn (string $reason): array => ['amount' => null, 'formula' => null, 'reason' => $reason]; + $ok = static fn (float $amount, string $formula): array => ['amount' => round($amount, 4), 'formula' => $formula, 'reason' => null]; + $w = self::kg($weight); + $within = static fn (float $value, float $edge): bool => $value <= $edge + 1e-9; + + if ($mode === self::SEGMENTS) { + foreach ((array) ($row['segments'] ?? []) as $segment) { + $lo = (float) ($segment['lo'] ?? 0); + $hi = isset($segment['hi']) ? (float) $segment['hi'] : INF; + if (! (($weight > $lo + 1e-9 || $lo <= 0.0) && $within($weight, $hi))) { + continue; + } + $type = (string) ($segment['type'] ?? ''); + if ($type === 'flat') { + return isset($segment['price']) ? $ok((float) $segment['price'], '一口价' . self::kg((float) $segment['price'])) : $none('该重量段不发'); + } + if (! isset($segment['base'], $segment['rate'])) { + return $none('该重量段不发'); + } + $base = (float) $segment['base']; + $rate = (float) $segment['rate']; + if ($type === 'first_add') { + $fw = (float) ($segment['fw'] ?? 0); + + return $within($weight, $fw) + ? $ok($base, '首重' . self::kg($base)) + : $ok($base + ($weight - $fw) * $rate, self::kg($base) . '+(' . $w . '-' . self::kg($fw) . ')×' . self::kg($rate)); + } + + return $ok($base + $weight * $rate, self::kg($base) . '+' . $w . '×' . self::kg($rate)); + } + + return $none('超出报价重量范围'); + } + + if (! in_array($mode, ['waybill_rate', 'first_additional', 'flat_bands', 'flat_then'], true)) { + return $none('特殊计费,需人工核算'); + } + + $bands = array_map('floatval', array_values((array) ($structure['flat_bands'] ?? []))); + $flat = array_values((array) ($row['flat'] ?? [])); + if (in_array($mode, ['flat_bands', 'flat_then'], true) && $bands !== [] && ($mode === 'flat_bands' || $within($weight, end($bands)))) { + foreach ($bands as $index => $edge) { + if ($within($weight, $edge)) { + $price = $flat[$index] ?? null; + + return is_numeric($price) ? $ok((float) $price, '≤' . self::kg($edge) . 'kg 一口价' . self::kg((float) $price)) : $none('该重量段不发'); + } + } + + return $none('超出报价重量范围'); + } + + $tiers = array_map('floatval', array_values((array) ($structure['rate_tiers'] ?? []))); + $index = count($tiers); + foreach ($tiers as $i => $edge) { + if ($within($weight, $edge)) { + $index = $i; + break; + } + } + $rate = $row['rates'][$index] ?? null; + $base = $row['base'] ?? null; + if (! is_numeric($rate) || ! is_numeric($base)) { + return $none('该重量段不发'); + } + $rate = (float) $rate; + $base = (float) $base; + $then = $mode === 'flat_then' ? (string) ($structure['then_mode'] ?? 'waybill_rate') : $mode; + if ($then === 'first_additional') { + $fw = (float) ($structure['first_weight'] ?? 0); + + return $within($weight, $fw) + ? $ok($base, '首重' . self::kg($base)) + : $ok($base + ($weight - $fw) * $rate, self::kg($base) . '+(' . $w . '-' . self::kg($fw) . ')×' . self::kg($rate)); + } + + return $ok($base + $weight * $rate, self::kg($base) . '+' . $w . '×' . self::kg($rate)); + } + + /** 按取整方式得到计费重量:ceil 向上取整,round 四舍五入(4.5 进为 5)。 */ + public static function roundWeight(float $weight, string $rounding): float + { + return match ($rounding) { + 'ceil' => (float) ceil(round($weight, 6)), + 'round' => (float) floor($weight + 0.5), + default => $weight, + }; + } + public static function usesRates(string $mode): bool { return in_array($mode, ['waybill_rate', 'first_additional', 'flat_then'], true); diff --git a/app/Support/QuoteManagementSnapshot.php b/app/Support/QuoteManagementSnapshot.php index 526b7409..440630d1 100644 --- a/app/Support/QuoteManagementSnapshot.php +++ b/app/Support/QuoteManagementSnapshot.php @@ -142,6 +142,23 @@ final class QuoteManagementSnapshot 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); @@ -155,6 +172,7 @@ final class QuoteManagementSnapshot '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(), diff --git a/database/migrations/2026_10_18_000000_add_billing_structure_to_business_courier_quote_versions.php b/database/migrations/2026_10_18_000000_add_billing_structure_to_business_courier_quote_versions.php new file mode 100644 index 00000000..41c6b3e6 --- /dev/null +++ b/database/migrations/2026_10_18_000000_add_billing_structure_to_business_courier_quote_versions.php @@ -0,0 +1,46 @@ +longText('billing_structure')->nullable(); + } + if (! Schema::hasColumn('business_courier_quote_versions', 'billing_rows')) { + $table->longText('billing_rows')->nullable(); + } + }); + } + + public function down(): void + { + if (! Schema::hasTable('business_courier_quote_versions')) { + return; + } + + Schema::table('business_courier_quote_versions', function (Blueprint $table): void { + foreach (['billing_structure', 'billing_rows'] as $column) { + if (Schema::hasColumn('business_courier_quote_versions', $column)) { + $table->dropColumn($column); + } + } + }); + } +}; diff --git a/resources/views/business/pages/courier-payables/quote-management.blade.php b/resources/views/business/pages/courier-payables/quote-management.blade.php index 1b26a3f4..63628bdc 100644 --- a/resources/views/business/pages/courier-payables/quote-management.blade.php +++ b/resources/views/business/pages/courier-payables/quote-management.blade.php @@ -865,6 +865,7 @@
原始范围
执行日期
公式单元格
+
系统计费规则