186 lines
8.3 KiB
PHP
186 lines
8.3 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;
|
||
|
||
/**
|
||
* 把整理好的结构化计费规则(标准计费规则_导入用.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')));
|
||
}
|
||
}
|