Compare commits

...

1 Commits

Author SHA1 Message Date
bca3c074ef 报价管理:存量报价结构化计费规则与运费核算 2026-10-19 08:45:28 +08:00
8 changed files with 783 additions and 0 deletions

View File

@ -0,0 +1,185 @@
<?php
namespace App\Console\Commands;
use App\Models\Business\CourierQuoteVersion;
use App\Models\Common\Company;
use App\Support\CourierQuoteStructure;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* 把整理好的结构化计费规则(标准计费规则_导入用.json)写入现有报价版本,供运费核算使用。
* 只写 billing_structure / billing_rows 两列,原始报价单元格、执行日期、状态都不动。
*/
class ImportCourierQuoteBillingRules extends Command
{
protected $signature = 'courier-quotes:import-billing-rules {file : 标准计费规则 JSON}
{--company= : 报价所属公司ID}
{--dry-run : 只校验并输出结果,不写入}
{--force : 覆盖已有且内容不同的计费规则}';
protected $description = '把存量报价的结构化计费规则批量写入报价版本(按版本号匹配,整批事务)';
private const MODES = ['waybill_rate', 'first_additional', 'flat_bands', 'flat_then', CourierQuoteStructure::SEGMENTS];
public function handle(): int
{
$companyId = (int) $this->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<string,mixed> $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<float> */
private function numbers(mixed $values): array
{
return array_values(array_map('floatval', array_filter((array) $values, 'is_numeric')));
}
}

View File

@ -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;

View File

@ -0,0 +1,151 @@
<?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,
];
$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<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) && 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;
}
}

View File

@ -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<string,mixed> $structure
* @param array<string,mixed> $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);

View File

@ -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(),

View File

@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 报价版本增加结构化计费规则,供运费核算使用;原始单元格(values_json)保留不动。
*
* billing_structure:{mode, flat_bands, then_mode, first_weight, rate_tiers, rounding, volumetric_divisor,
* weight_limit, min_weight(起算重量), per_ticket_surcharge, notes}
* billing_rows: [{region, flat, base, rates}] 或分段计费 [{region, segments}]
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('business_courier_quote_versions')) {
return;
}
Schema::table('business_courier_quote_versions', function (Blueprint $table): void {
if (! Schema::hasColumn('business_courier_quote_versions', 'billing_structure')) {
$table->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);
}
}
});
}
};

View File

@ -865,6 +865,7 @@
<div><dt>原始范围</dt><dd data-quote-detail="range"></dd></div>
<div><dt>执行日期</dt><dd data-quote-detail="date"></dd></div>
<div><dt>公式单元格</dt><dd data-quote-detail="formulas"></dd></div>
<div><dt>系统计费规则</dt><dd data-quote-detail="billing"></dd></div>
</dl>
<div class="quote-signed" data-quote-signed hidden>
<span class="material-icons-outlined" aria-hidden="true">task_alt</span>
@ -1180,6 +1181,7 @@
setDetail('range', text(quote.source_range, '-'));
setDetail('date', text(quote.period_label || quote.effective_date, 'Excel 未标注'));
setDetail('formulas', String(quote.formula_count));
setDetail('billing', text(quote.billing_summary, '未设置(运费需人工核算)'));
var originals = root.querySelector('[data-stock-original-links]');
originals.replaceChildren();
(quote.stock_originals || []).forEach(function (file, index, files) {

View File

@ -0,0 +1,259 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Business\CourierQuoteChange;
use App\Models\Business\CourierQuoteVersion;
use App\Support\CourierQuoteFreightCalculator;
use App\Support\CourierQuoteStructure;
use App\Support\QuoteManagementSnapshot;
use Illuminate\Support\Carbon;
use Tests\Feature\FeatureTestCase;
class CourierQuoteBillingRulesTest extends FeatureTestCase
{
private array $files = [];
protected function tearDown(): void
{
foreach ($this->files as $file) {
@unlink($file);
}
parent::tearDown();
}
/** 用各家原表备注里自带的算例核对计费口径。 */
public function testFreightMatchesWorkedExamplesFromQuoteRemarks(): void
{
$f = fn (array $structure, array $row, float $kg, string $rounding = 'none') => CourierQuoteStructure::freight(
$structure, $row, CourierQuoteStructure::roundWeight($kg, $rounding)
)['amount'];
// 江西新建中通:15kg 广东 = 5.5 + 10×0.43 + 5×0.63(累进,用分段表示)
$progressive = ['mode' => 'segments'];
$gd = ['region' => '广东', 'segments' => [
['lo' => 0, 'hi' => 10, 'type' => 'waybill', 'base' => 5.5, 'rate' => 0.43],
['lo' => 10, 'hi' => null, 'type' => 'first_add', 'base' => 9.8, 'fw' => 10, 'rate' => 0.63],
]];
$this->assertEqualsWithDelta(12.95, $f($progressive, $gd, 15), 0.0001);
$this->assertEqualsWithDelta(9.8, $f($progressive, $gd, 10), 0.0001);
// 迈当极兔:8.1kg 广东向上取整按 9kg,3 + 9×1.1 = 12.9;3kg 内一口价 4.2
$md = ['mode' => 'flat_then', 'flat_bands' => [3], 'then_mode' => 'waybill_rate', 'rate_tiers' => [10]];
$mdRow = ['region' => '广东', 'flat' => [4.2], 'base' => 3, 'rates' => [1.1, 1.4]];
$this->assertEqualsWithDelta(12.9, $f($md, $mdRow, 8.1, 'ceil'), 0.0001);
$this->assertEqualsWithDelta(4.2, $f($md, $mdRow, 2.6, 'ceil'), 0.0001);
$this->assertEqualsWithDelta(3 + 11 * 1.4, $f($md, $mdRow, 10.2, 'ceil'), 0.0001);
// 顺兴中通快运:10.4kg = (10.4-10)×1.07 + 首重最低收费 24
$sx = ['mode' => 'first_additional', 'first_weight' => 10, 'rate_tiers' => []];
$this->assertEqualsWithDelta(24.428, $f($sx, ['region' => '安徽', 'base' => 24, 'rates' => [1.07]], 10.4), 0.0001);
$this->assertEqualsWithDelta(24.0, $f($sx, ['region' => '安徽', 'base' => 24, 'rates' => [1.07]], 6), 0.0001);
// 彩艺德邦:四舍五入,4.4 按 4kg(首重 5kg 内 13 元),5.5 按 6kg
$cy = ['mode' => 'first_additional', 'first_weight' => 5, 'rate_tiers' => []];
$cyRow = ['region' => '江苏', 'base' => 13, 'rates' => [1.6]];
$this->assertEqualsWithDelta(13.0, $f($cy, $cyRow, 4.4, 'round'), 0.0001);
$this->assertEqualsWithDelta(14.6, $f($cy, $cyRow, 5.5, 'round'), 0.0001);
// 常州圆通:按总重量所在区间取费率,区间上限含本数
$yt = ['mode' => 'waybill_rate', 'rate_tiers' => [35, 50]];
$ytRow = ['region' => '江苏', 'base' => 3.6, 'rates' => [0.48, 0.98, 1.28]];
$this->assertEqualsWithDelta(3.6 + 35 * 0.48, $f($yt, $ytRow, 35), 0.0001);
$this->assertEqualsWithDelta(3.6 + 35.5 * 0.98, $f($yt, $ytRow, 35.5), 0.0001);
$this->assertEqualsWithDelta(3.6 + 60 * 1.28, $f($yt, $ytRow, 60), 0.0001);
// 派雅极兔:某一段原表为「不发」(价格为空);超过最后一段不在报价范围
$py = ['mode' => 'flat_bands', 'flat_bands' => [3, 5]];
$pyRow = ['region' => '江苏', 'flat' => [4, null]];
$this->assertEqualsWithDelta(4.0, $f($py, $pyRow, 2), 0.0001);
$this->assertSame('该重量段不发', CourierQuoteStructure::freight($py, $pyRow, 4)['reason']);
$this->assertSame('超出报价重量范围', CourierQuoteStructure::freight($py, $pyRow, 6)['reason']);
$this->assertSame('特殊计费,需人工核算', CourierQuoteStructure::freight(['mode' => 'other'], [], 3)['reason']);
}
public function testImportCommandWritesRulesAndCalculatorUsesThemByShipDate(): void
{
$old = $this->version('QB-101', '常州圆通其他', 'CZYTOQT', '2026-06-01', '2026-09-19');
$new = $this->version('QB-102', '常州圆通其他', 'CZYTOQT', '2026-09-20', null);
$seg = $this->version('QB-103', '江西新建中通', 'JXXJZTO', '2000-01-01', null);
$manual = $this->version('QB-104', '华韵安能', 'HYAN', '2025-07-01', null);
$file = $this->rulesFile([
$this->waybill('QB-101', '常州圆通其他', 3.5),
$this->waybill('QB-102', '常州圆通其他', 3.6),
[
'version_id' => 'QB-103', 'company_name' => '江西新建中通', 'conversion' => 'needs_segment_mode',
'structure' => ['mode' => 'other', 'flat_bands' => [], 'then_mode' => '', 'first_weight' => null, 'rate_tiers' => [], 'rounding' => 'none'],
'segments_rows' => [['region' => '广东', 'segments' => [
['lo' => 0, 'hi' => 10, 'type' => 'waybill', 'base' => 5.5, 'rate' => 0.43],
['lo' => 10, 'hi' => null, 'type' => 'first_add', 'base' => 9.8, 'fw' => 10, 'rate' => 0.63],
]]],
'params' => ['volumetric_divisor' => 10000, 'weight_limit_kg' => 45, 'surcharges' => [], 'notes' => []],
],
['version_id' => 'QB-104', 'company_name' => '华韵安能', 'conversion' => 'manual', 'manual_reason' => '按体积计费'],
]);
$this->artisan('courier-quotes:import-billing-rules', ['file' => $file, '--company' => $this->company->id, '--dry-run' => true])
->assertExitCode(0);
$this->assertNull($old->fresh()->billing_structure);
$this->artisan('courier-quotes:import-billing-rules', ['file' => $file, '--company' => $this->company->id])
->assertExitCode(0);
$this->assertSame('waybill_rate', $old->fresh()->billing_structure['mode']);
$this->assertEquals([35, 50], $new->fresh()->billing_structure['rate_tiers']);
$this->assertSame('segments', $seg->fresh()->billing_structure['mode']);
$this->assertNull($manual->fresh()->billing_structure);
$calc = new CourierQuoteFreightCalculator();
// 发货日期决定用哪一版:9 月 1 日走旧版面单 3.5,9 月 25 日走新版面单 3.6
$before = $calc->calculate('CZYTOQT', Carbon::parse('2026-09-01'), '江苏省', 10);
$this->assertSame('QB-101', $before['version_code']);
$this->assertEqualsWithDelta(3.5 + 10 * 0.48, $before['amount'], 0.001);
$after = $calc->calculate('CZYTOQT', Carbon::parse('2026-09-25'), '江苏', 10);
$this->assertSame('QB-102', $after['version_code']);
$this->assertEqualsWithDelta(3.6 + 10 * 0.48, $after['amount'], 0.001);
// 计泡:60×50×40/6000 = 20kg > 实重 10kg
$volume = $calc->calculate('CZYTOQT', Carbon::parse('2026-09-25'), '江苏', 10, 60, 50, 40);
$this->assertEqualsWithDelta(20.0, $volume['volume_weight'], 0.001);
$this->assertEqualsWithDelta(3.6 + 20 * 0.48, $volume['amount'], 0.001);
// 城市单列行优先:深圳走「深圳(广东)」,其他广东城市走广东
$this->assertEqualsWithDelta(3.6 + 2 * 1.0, $calc->calculate('CZYTOQT', Carbon::parse('2026-09-25'), '广东省', 2, city: '深圳')['amount'], 0.001);
$this->assertEqualsWithDelta(3.6 + 2 * 0.75, $calc->calculate('CZYTOQT', Carbon::parse('2026-09-25'), '广东省', 2, city: '广州')['amount'], 0.001);
$this->assertEqualsWithDelta(12.95, $calc->calculate('JXXJZTO', Carbon::parse('2026-10-01'), '广东', 15)['amount'], 0.001);
// 没有快递编码的存量报价按快递名称查
$seg->forceFill(['courier_expcode' => null])->save();
$this->assertEqualsWithDelta(12.95, (new CourierQuoteFreightCalculator())->calculate('江西新建中通', Carbon::parse('2026-10-01'), '广东', 15)['amount'], 0.001);
$seg->forceFill(['courier_expcode' => 'JXXJZTO'])->save();
// 起算重量:靓阁不足 16kg 按 16kg(落在 16-17kg 段 40 元)
$this->version('QB-105', '靓阁快递', 'LGKD', '2024-03-12', null)->forceFill([
'billing_structure' => ['mode' => 'flat_bands', 'flat_bands' => [17.0, 18.0], 'rounding' => 'none', 'min_weight' => 16.0, 'weight_limit' => 45.0],
'billing_rows' => [['region' => '江苏', 'flat' => [40.0, 50.0], 'base' => null, 'rates' => []]],
])->save();
$light = $calc->calculate('LGKD', Carbon::parse('2026-10-01'), '江苏', 3);
$this->assertSame(16.0, $light['billable_weight']);
$this->assertEqualsWithDelta(40.0, $light['amount'], 0.001);
$this->assertEqualsWithDelta(50.0, $calc->calculate('LGKD', Carbon::parse('2026-10-01'), '江苏', 17.5)['amount'], 0.001);
$this->assertSame('超出限重 45kg', $calc->calculate('JXXJZTO', Carbon::parse('2026-10-01'), '广东', 46)['reason']);
$this->assertStringContainsString('未覆盖目的地', $calc->calculate('CZYTOQT', Carbon::parse('2026-09-25'), '西藏', 2)['reason']);
$this->assertStringContainsString('需人工核算', $calc->calculate('HYAN', Carbon::parse('2026-09-25'), '江苏', 2)['reason']);
$this->assertSame('发货日期没有生效的报价', $calc->calculate('CZYTOQT', Carbon::parse('2026-01-01'), '江苏', 2)['reason']);
// 再导一次内容相同 → 跳过;内容不同且不加 --force → 整批报错不写
$this->artisan('courier-quotes:import-billing-rules', ['file' => $file, '--company' => $this->company->id])->assertExitCode(0);
$changed = $this->rulesFile([$this->waybill('QB-102', '常州圆通其他', 9.9)]);
$this->artisan('courier-quotes:import-billing-rules', ['file' => $changed, '--company' => $this->company->id])->assertExitCode(1);
$this->assertEqualsWithDelta(3.6, $new->fresh()->billing_rows[0]['base'], 0.0001);
$this->artisan('courier-quotes:import-billing-rules', ['file' => $changed, '--company' => $this->company->id, '--force' => true])->assertExitCode(0);
$this->assertEqualsWithDelta(9.9, $new->fresh()->billing_rows[0]['base'], 0.0001);
}
public function testImportRejectsWholeBatchWhenCompanyNameDoesNotMatch(): void
{
$a = $this->version('QB-201', '沭阳德邦', 'SYDB', '2026-02-01', null);
$this->version('QB-202', '沭阳德邦其他', 'SYDBQT', '2026-02-01', null);
$file = $this->rulesFile([$this->waybill('QB-201', '沭阳德邦', 6.8), $this->waybill('QB-202', '别的快递', 6.8)]);
$this->artisan('courier-quotes:import-billing-rules', ['file' => $file, '--company' => $this->company->id])->assertExitCode(1);
$this->assertNull($a->fresh()->billing_structure);
}
public function testQuoteDetailShowsBillingSummary(): void
{
$version = $this->version('QB-301', '彩艺德邦', 'CYDB', '2024-07-01', null);
$version->forceFill([
'billing_structure' => ['mode' => 'first_additional', 'flat_bands' => [], 'then_mode' => '', 'first_weight' => 5.0, 'rate_tiers' => [],
'rounding' => 'round', 'volumetric_divisor' => 10000, 'weight_limit' => 60.0, 'per_ticket_surcharge' => 1.0],
'billing_rows' => [['region' => '江苏', 'flat' => [], 'base' => 13.0, 'rates' => [1.6]]],
])->save();
$quotes = (new QuoteManagementSnapshot('/nonexistent-billing.json'))->load()['quotes'];
$quote = collect($quotes)->firstWhere('id', 'QB-301');
$this->assertStringContainsString('首重5kg内按首重价', $quote['billing_summary']);
$this->assertStringContainsString('每票加收 1 元', $quote['billing_summary']);
$this->assertStringContainsString('1 个地区', $quote['billing_summary']);
$this->assertCalculatorAddsPerTicket();
}
private function assertCalculatorAddsPerTicket(): void
{
$result = (new CourierQuoteFreightCalculator())->calculate('CYDB', Carbon::parse('2026-01-01'), '江苏', 5.5);
$this->assertSame(6.0, $result['billable_weight']);
$this->assertEqualsWithDelta(15.6, $result['amount'], 0.001);
$this->assertSame('13+(6-5)×1.6+每票1', $result['formula']);
}
private function waybill(string $code, string $name, float $base): array
{
$rows = [];
foreach ([['江苏', 0.48, 0.98, 1.28], ['广东', 0.75, 3, 3.3], ['深圳(广东)', 1, 3, 3.3]] as [$region, $r1, $r2, $r3]) {
$rows[] = ['region' => $region, 'flat' => [], 'base' => $base, 'rates' => [$r1, $r2, $r3], 'source_row' => 2];
}
return [
'version_id' => $code, 'company_name' => $name, 'conversion' => 'direct',
'structure' => ['mode' => 'waybill_rate', 'flat_bands' => [], 'then_mode' => '', 'first_weight' => null, 'rate_tiers' => [35.0, 50.0], 'rounding' => 'none'],
'rows' => $rows,
'params' => ['volumetric_divisor' => 6000, 'weight_limit_kg' => null, 'surcharges' => [], 'notes' => ['面单预充6元/单']],
];
}
private function rulesFile(array $versions): string
{
$path = storage_path('framework/testing/billing-rules-' . uniqid('', true) . '.json');
@mkdir(dirname($path), 0777, true);
file_put_contents($path, json_encode(['versions' => $versions], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
$this->files[] = $path;
return $path;
}
private function version(string $code, string $name, string $expcode, string $from, ?string $to): CourierQuoteVersion
{
$change = CourierQuoteChange::create([
'company_id' => $this->company->id,
'number' => 'BG-T-' . $code,
'source_quote_id' => $code,
'source_sheet_name' => $name,
'carrier_name' => $name,
'quote_type' => '标准快递报价',
'change_type' => '面单调整',
'status' => CourierQuoteChange::STATUS_APPROVED,
'effective_date' => $from,
'is_retrospective' => false,
'recalculation_status' => CourierQuoteChange::RECALCULATION_NOT_REQUIRED,
'summary' => '测试报价',
'created_by' => $this->user->id,
'submitted_by' => $this->user->id,
'submitted_at' => now(),
'approved_by' => $this->user->id,
'approved_at' => now(),
]);
return CourierQuoteVersion::create([
'company_id' => $this->company->id,
'version_code' => $code,
'source_quote_id' => $code,
'source_change_id' => $change->id,
'sheet_name' => $name,
'company_name' => $name,
'carrier_name' => $name,
'courier_expcode' => $expcode,
'quote_type' => '标准快递报价',
'effective_date' => $from,
'end_date' => $to,
'source_range' => 'A1:C2',
'values_json' => json_encode([['省份', '面单', '费率']], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'formulas_json' => json_encode([[]], JSON_THROW_ON_ERROR),
'status' => 'approved',
'signature_status' => '历史签署资料已归档',
'created_by' => $this->user->id,
'approved_by' => $this->user->id,
]);
}
}