281 lines
15 KiB
PHP
281 lines
15 KiB
PHP
<?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;
|
||
use RuntimeException;
|
||
|
||
/**
|
||
* 把整理好的结构化计费规则(标准计费规则_导入用.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 = [];
|
||
$seen = [];
|
||
foreach ($items as $item) {
|
||
if (! is_array($item)) {
|
||
$report[] = ['', '', '错误', '版本条目必须是对象'];
|
||
continue;
|
||
}
|
||
$code = (string) ($item['version_id'] ?? '');
|
||
if (isset($seen[$code])) {
|
||
$report[] = [$code, '', '错误', '同一文件中的版本号不能重复'];
|
||
continue;
|
||
}
|
||
$seen[$code] = true;
|
||
[$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;
|
||
}
|
||
|
||
try {
|
||
DB::transaction(function () use ($plan): void {
|
||
foreach ($plan as [$version, $structure, $rows]) {
|
||
$current = DB::table($version->getTable())->where('id', $version->id)->lockForUpdate()->first();
|
||
if (! $current || $current->company_name !== $version->company_name
|
||
|| json_decode($current->billing_structure ?? 'null', true) != $version->billing_structure
|
||
|| json_decode($current->billing_rows ?? 'null', true) != $version->billing_rows) {
|
||
throw new RuntimeException('报价在校验后被其他操作修改,整批未写入,请重新校验。');
|
||
}
|
||
$version->forceFill(['billing_structure' => $structure, 'billing_rows' => $rows])->save();
|
||
}
|
||
});
|
||
} catch (RuntimeException $exception) {
|
||
$this->error($exception->getMessage());
|
||
return self::FAILURE;
|
||
}
|
||
$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()->allCompanies()->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];
|
||
}
|
||
if (($error = $this->inputError($item)) !== null) {
|
||
return ['错误', $error, 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;
|
||
}
|
||
|
||
/** 金额导入拒绝负数、歧义分段和不支持的附加条件,不静默改写计费含义。 */
|
||
private function inputError(array $item): ?string
|
||
{
|
||
$structure = $item['structure'] ?? null;
|
||
$params = $item['params'] ?? [];
|
||
if (! is_array($structure) || ! is_array($params)) {
|
||
return 'structure 和 params 必须是对象';
|
||
}
|
||
if (! array_key_exists($structure['rounding'] ?? 'none', CourierQuoteStructure::ROUNDING)) {
|
||
return '不支持的重量取整方式';
|
||
}
|
||
$valid = static fn ($v): bool => is_numeric($v) && is_finite((float) $v) && (float) $v >= 0;
|
||
foreach ([$params['volumetric_divisor'] ?? null, $params['weight_limit_kg'] ?? null,
|
||
$structure['min_weight'] ?? $params['min_weight_kg'] ?? null, $structure['first_weight'] ?? null] as $v) {
|
||
if ($v !== null && ! $valid($v)) return '重量、体积系数必须是非负有限数字';
|
||
}
|
||
if (isset($params['volumetric_divisor']) && (float) $params['volumetric_divisor'] <= 0) return '体积系数必须大于0';
|
||
$minimum = (float) ($structure['min_weight'] ?? $params['min_weight_kg'] ?? 0);
|
||
if (($params['weight_limit_kg'] ?? 0) > 0 && $minimum > $params['weight_limit_kg']) return '起算重量不能大于限重';
|
||
foreach (['flat_bands', 'rate_tiers'] as $field) {
|
||
$previous = 0;
|
||
if (! is_array($structure[$field] ?? [])) return '重量分段必须是数组';
|
||
foreach ($structure[$field] ?? [] as $edge) {
|
||
if (! $valid($edge) || (float) $edge <= $previous) return '重量节点必须为严格递增的正数';
|
||
$previous = (float) $edge;
|
||
}
|
||
}
|
||
$mode = ($item['conversion'] ?? '') === 'needs_segment_mode' ? CourierQuoteStructure::SEGMENTS : ($structure['mode'] ?? '');
|
||
if (in_array($mode, ['flat_bands', 'flat_then'], true) && empty($structure['flat_bands'])) return '缺少一口价重量节点';
|
||
if ($mode === 'flat_then' && ! in_array($structure['then_mode'] ?? '', ['waybill_rate', 'first_additional'], true)) return '不支持的超重计费方式';
|
||
if (CourierQuoteStructure::usesFirstWeight($mode, $structure['then_mode'] ?? '') && ! (($structure['first_weight'] ?? 0) > 0)) return '首重必须大于0';
|
||
if ($mode === 'flat_then' && ! empty($structure['rate_tiers']) && min($structure['rate_tiers']) <= max($structure['flat_bands'])) return '超重费率节点必须大于一口价最后节点';
|
||
if (! is_array($params['surcharges'] ?? [])) return '附加费必须是数组';
|
||
foreach ($params['surcharges'] ?? [] as $fee) {
|
||
if (! is_array($fee) || ($fee['condition'] ?? '') !== '每票' || ! $valid($fee['amount'] ?? null)) return '附加费条件不支持自动核算,需人工核对';
|
||
}
|
||
$rows = $item[$mode === CourierQuoteStructure::SEGMENTS ? 'segments_rows' : 'rows'] ?? null;
|
||
if (! is_array($rows) || $rows === []) return '没有任何地区价格';
|
||
$regions = [];
|
||
foreach ($rows as $row) {
|
||
if (! is_array($row) || ! is_string($row['region'] ?? null) || trim($row['region']) === '') return '地区行格式不正确';
|
||
$region = mb_strtolower(preg_replace('/\s+/u', '', $row['region']));
|
||
if (isset($regions[$region])) return '存在重复地区行';
|
||
$regions[$region] = true;
|
||
if ($mode === CourierQuoteStructure::SEGMENTS) {
|
||
if (! is_array($row['segments'] ?? null) || $row['segments'] === []) return '缺少地区计费分段';
|
||
$previous = 0.0;
|
||
foreach ($row['segments'] as $segment) {
|
||
if (! is_array($segment) || ! $valid($segment['lo'] ?? null)
|
||
|| (float) $segment['lo'] < $previous || ! in_array($segment['type'] ?? '', ['flat', 'waybill', 'first_add'], true)) return '分段类型不支持或存在重叠';
|
||
$hi = $segment['hi'] ?? null;
|
||
if ($hi !== null && (! $valid($hi) || (float) $hi <= (float) $segment['lo'])) return '分段上限必须大于下限';
|
||
$previous = $hi === null ? INF : (float) $hi;
|
||
foreach (['price', 'base', 'rate', 'fw'] as $key) {
|
||
if (isset($segment[$key]) && ! $valid($segment[$key])) return '分段金额和重量必须是非负有限数字';
|
||
}
|
||
if ($segment['type'] === 'first_add' && ! $valid($segment['fw'] ?? null)) return '分段首重缺失';
|
||
}
|
||
} else {
|
||
if (! is_array($row['flat'] ?? []) || ! is_array($row['rates'] ?? [])) return '价格必须是数组';
|
||
if (count($row['flat'] ?? []) !== count($structure['flat_bands'] ?? [])
|
||
|| (CourierQuoteStructure::usesRates($mode) && count($row['rates'] ?? []) !== count($structure['rate_tiers'] ?? []) + 1)) return '价格列数与重量节点不匹配';
|
||
foreach (array_merge($row['flat'] ?? [], $row['rates'] ?? [], [$row['base'] ?? null]) as $v) {
|
||
if ($v !== null && ! $valid($v)) return '价格必须是非负有限数字或null(不发)';
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** @return list<float> */
|
||
private function numbers(mixed $values): array
|
||
{
|
||
return array_values(array_map('floatval', array_filter((array) $values, 'is_numeric')));
|
||
}
|
||
}
|