汽车信息基础库

This commit is contained in:
mzl
2025-09-01 10:08:51 +08:00
parent 5c5ab30bec
commit 847303a8fa
34 changed files with 2167 additions and 230 deletions
@@ -0,0 +1,33 @@
<?php
namespace app\common\controller;
use app\common\controller\BaseController;
use app\common\traits\crud\CrudControllerTrait;
use app\common\service\VehDictI18nService;
class VehDictI18nController extends BaseController
{
use CrudControllerTrait;
protected $service;
public function __construct()
{
$this->service = new VehDictI18nService();
}
/**
* 列表页数据格式化
*
* @param array $data
* @return array
*/
// protected function indexFormat($data)
// {
// if (isset($data['created_at']))
// $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
// if (isset($data['updated_at']))
// $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
// return $data;
// }
}
@@ -0,0 +1,33 @@
<?php
namespace app\common\controller;
use app\common\controller\BaseController;
use app\common\traits\crud\CrudControllerTrait;
use app\common\service\VehDictMappingService;
class VehDictMappingController extends BaseController
{
use CrudControllerTrait;
protected $service;
public function __construct()
{
$this->service = new VehDictMappingService();
}
/**
* 列表页数据格式化
*
* @param array $data
* @return array
*/
// protected function indexFormat($data)
// {
// if (isset($data['created_at']))
// $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
// if (isset($data['updated_at']))
// $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
// return $data;
// }
}
@@ -0,0 +1,33 @@
<?php
namespace app\common\controller;
use app\common\controller\BaseController;
use app\common\traits\crud\CrudControllerTrait;
use app\common\service\VehVehicleListingService;
class VehVehicleListingController extends BaseController
{
use CrudControllerTrait;
protected $service;
public function __construct()
{
$this->service = new VehVehicleListingService();
}
/**
* 列表页数据格式化
*
* @param array $data
* @return array
*/
// protected function indexFormat($data)
// {
// if (isset($data['created_at']))
// $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
// if (isset($data['updated_at']))
// $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
// return $data;
// }
}
@@ -0,0 +1,204 @@
<?php
namespace app\common\controller;
use app\common\controller\BaseController;
use app\common\traits\crud\CrudControllerTrait;
use app\common\service\VehVehicleSkuService;
use support\exception\BusinessException;
use support\Request;
class VehVehicleSkuController extends BaseController
{
use CrudControllerTrait;
protected $service;
public function __construct()
{
$this->service = new VehVehicleSkuService();
}
/**
* 列表页数据格式化
*
* @param array $data
* @return array
*/
// protected function indexFormat($data)
// {
// if (isset($data['created_at']))
// $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
// if (isset($data['updated_at']))
// $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
// return $data;
// }
protected function showFormat($data)
{
if (isset($data['created_at']))
$data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
if (isset($data['updated_at']))
$data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
return $data;
}
public function store(Request $request)
{
try {
$data = $this->validate($this->service->getValidator(), $request->post(), 'store');
$res = $this->service->serviceStore($request, $data);
return $this->success($res, '创建成功');
} catch (\PDOException $e) {
$errorCode = $e->getCode();
$errorMessage = $e->getMessage();
if ($errorCode == '23000') {
if (strpos($errorMessage, "Duplicate entry") !== false && strpos($errorMessage, "uk_fingerprint") !== false) {
return $this->error("该车辆指纹已存在,请勿重复提交");
}
// 其他类型的完整性约束错误
throw new BusinessException("数据完整性错误", 0, $e);
}
// 重新抛出其他类型的异常
throw $e;
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
public function update(Request $request)
{
try {
$data = $this->validate($this->service->getValidator(), $request->post(), 'update');
$res = $this->service->serviceUpdate($request, $data);
return $this->success([], '更新成功');
} catch (\PDOException $e) {
$errorCode = $e->getCode();
$errorMessage = $e->getMessage();
if ($errorCode == '23000') {
if (strpos($errorMessage, "Duplicate entry") !== false && strpos($errorMessage, "uk_fingerprint") !== false) {
return $this->error("该车辆指纹已存在,请勿重复提交");
}
// 其他类型的完整性约束错误
throw new BusinessException("数据完整性错误", 0, $e);
}
// 重新抛出其他类型的异常
throw $e;
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
public function index(Request $request)
{
$data = [
'sku_code' , // 'SKU编码,系统生成',
'fingerprint' , // '规格指纹(sha256)去重',
'brand_code' , // '品牌代码,如 BYD、HYUNDAI、KIA',
'brand_name' , // '品牌名称展示,如 比亚迪、现代、起亚',
'series_code' , // '车系代码,如 ELANTRA、SONGPLUS',
'series_name' , // '车系名称展示',
'model_code' , // '车型/配置代码(工厂/内部编码)',
'model_name' , // '车型/配置名称(可读,如 伊兰特 1.5L、海狮07)',
'model_year' , // '年款(YYYY)',
'trim_code' , // '版型代码(TRIM)',
'trim_level_code' , // '配置等级代码(TRIM_LEVEL)',
'edition_code' , // '特别版代码(EDITION)',
'trim' , // '版型/细分配置(如 TOP旗舰版、荣耀版)',
'trim_level' , // '配置等级(豪华型/旗舰型等,便于筛选)',
'edition_name' , // '特别版/纪念版(如 荣耀版、致敬版等)',
'body_type_code' , // '车身类型:SEDAN/SUV/HB/MPV/Pickup',
'drive_type_code' , // '驱动:FWD/RWD/AWD/4WD',
'motor_layout_code' , // '电驱/电机布置(MOTOR_LAYOUT):FWD/RWD/AWD/DM-i/DM-p 等',
'fuel_type_code' , // '能源:GAS/HEV/PHEV/EV/EREV',
'engine_disp_l' , // '排量(L),如 1.5;纯电为空',
'engine_turbo' , // '0' COMMENT '是否涡轮(0/1)',
'engine_power_kw' , // '最大功率(kW)',
'battery_kwh' , // '电池容量(kWh)',
'transmission_code' , // '变速箱:MT/AT/CVT/DCT/1SPEED',
'gears' , // '挡位数(EV多为1)',
'range_km' , // '续航里程(km)',
'range_std_code' , // '续航标准:CLTC/WLTP/EPA/NEDC',
'doors' , // '门数',
'seats' , // '座位数',
'weight_kg' , // '整备质量(kg)',
'dimensions_mm' , // '尺寸(mm):{length,width,height,wheelbase}',
'color_exterior_code' , // '外观色代码',
'color_exterior_text' , // '外观色名称',
'color_interior_code' , // '内饰色代码',
'color_interior_text' , // '内饰色名称',
'options_official' , // '官方选装(结构化 JSON)',
'features' , // '功能/安全/智能配置(结构化 JSON)',
'emission_std_code' , // '排放标准:CHN6/EURO6/EPA',
'origin_country' , // '原产国 ISO-3166-1 alpha-2',
'currency' , // 'CNY' COMMENT '币种 ISO-4217',
'msrp' , // '建议零售价/参考价',
'is_active' , // '1' COMMENT '启用(1)/禁用(0)',
'corp_id' , // '0' COMMENT '租户/公司(全局库可写0)',
'remark' , // '备注',
'created_by' , // '0',
'updated_by' , // '0',
];
$data = [
'fingerprint' , // '规格指纹(sha256)去重',
'brand_name' , // '品牌名称展示,如 比亚迪、现代、起亚',
'series_name' , // '车系名称展示',
'model_name' , // '车型/配置名称(可读,如 伊兰特 1.5L、海狮07)',
'model_year' , // '年款(YYYY)',
'trim' , // '版型/细分配置(如 TOP旗舰版、荣耀版)',
'trim_level' , // '配置等级(豪华型/旗舰型等,便于筛选)',
'edition_name' , // '特别版/纪念版(如 荣耀版、致敬版等)',
'engine_disp_l' , // '排量(L),如 1.5;纯电为空',
'engine_turbo' , // '0' COMMENT '是否涡轮(0/1)',
'engine_power_kw' , // '最大功率(kW)',
'battery_kwh' , // '电池容量(kWh)',
'gears' , // '挡位数(EV多为1)',
'range_km' , // '续航里程(km)',
'doors' , // '门数',
'seats' , // '座位数',
'weight_kg' , // '整备质量(kg)',
'dimensions_mm' , // '尺寸(mm):{length,width,height,wheelbase}',
'color_exterior_text' , // '外观色名称',
'color_interior_text' , // '内饰色名称',
'options_official' , // '官方选装(结构化 JSON)',
'features' , // '功能/安全/智能配置(结构化 JSON)',
'origin_country' , // '原产国 ISO-3166-1 alpha-2',
'currency' , // 'CNY' COMMENT '币种 ISO-4217',
'msrp' , // '建议零售价/参考价',
'is_active' , // '1' COMMENT '启用(1)/禁用(0)',
'corp_id' , // '0' COMMENT '租户/公司(全局库可写0)',
'remark' , // '备注',
'created_by' , // '0',
'updated_by' , // '0',
'body_type', // '车身类型'
'drive_type', // '驱动:FWD/RWD/AWD/4WD',
'motor_layout', // '电机布置(MOTOR_LAYOUT):FWD/RWD/AWD/DM-i/DM-p 等'
'fuel_type', // '能源:GAS/HEV/PHEV/EV/EREV'
'transmission', // '变速箱:MT/AT/CVT/DCT/1SPEED'
'range_std', // '续航标准:CLTC/WLTP/EPA/NEDC'
'emission_std', // '排放标准:CHN6/EURO6/EPA'
'sku_code' , // 'SKU编码,系统生成',
'brand_code' , // '品牌代码,如 BYD、HYUNDAI、KIA',
'series_code' , // '车系代码,如 ELANTRA、SONGPLUS',
'model_code' , // '车型/配置代码(工厂/内部编码)',
'trim_code' , // '版型代码(TRIM)',
'trim_level_code' , // '配置等级代码(TRIM_LEVEL)',
'edition_code' , // '特别版代码(EDITION)',
'color_exterior_code' , // '外观色代码',
'color_interior_code' , // '内饰色代码',
'body_type_code' , // '车身类型:SEDAN/SUV/HB/MPV/Pickup',
'drive_type_code' , // '驱动:FWD/RWD/AWD/4WD',
'motor_layout_code' , // '电驱/电机布置(MOTOR_LAYOUT):FWD/RWD/AWD/DM-i/DM-p 等',
'fuel_type_code' , // '能源:GAS/HEV/PHEV/EV/EREV',
'transmission_code' , // '变速箱:MT/AT/CVT/DCT/1SPEED',
'range_std_code' , // '续航标准:CLTC/WLTP/EPA/NEDC',
'emission_std_code' , // '排放标准:CHN6/EURO6/EPA',
];
}
}
@@ -0,0 +1,33 @@
<?php
namespace app\common\controller;
use app\common\controller\BaseController;
use app\common\traits\crud\CrudControllerTrait;
use app\common\service\VehVehicleUnitService;
class VehVehicleUnitController extends BaseController
{
use CrudControllerTrait;
protected $service;
public function __construct()
{
$this->service = new VehVehicleUnitService();
}
/**
* 列表页数据格式化
*
* @param array $data
* @return array
*/
// protected function indexFormat($data)
// {
// if (isset($data['created_at']))
// $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
// if (isset($data['updated_at']))
// $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
// return $data;
// }
}
+262
View File
@@ -0,0 +1,262 @@
<?php
namespace app\common\logic;
/**
* Fingerprint
*
* 目标:
* - 默认“强制所有字段参与计算”,保证相同记录生成同一指纹
* - 通过统一规范化策略,避免无意义差异(空白、大小写、JSON键序)导致指纹抖动
* - 仅排除技术字段:id、fingerprint(可通过参数自行增删)
*
* 用法:
* $hash = Fingerprint::makeFromArray($payload); // 创建前
* $hash = Fingerprint::makeFromModel($skuModel); // 已有模型
* $hash = Fingerprint::makeFromArray($payload, ['exclude'=>['msrp']]); // 可选排除
*/
class Fingerprint
{
/** 你的 veh_vehicle_sku 所有列(保持与表结构一致,顺序无关但建议完整) */
public const COLS = [
'id','sku_code','fingerprint',
'brand_code','brand_name','series_code','series_name',
'model_code','model_name','model_year',
'trim_code','trim_level_code','edition_code',
'trim','trim_level','edition_name',
'body_type_code','drive_type_code','motor_layout_code','fuel_type_code',
'engine_disp_l','engine_turbo','engine_power_kw','battery_kwh',
'transmission_code','gears','range_km','range_std_code',
'doors','seats','weight_kg','dimensions_mm',
'color_exterior_code','color_exterior_text',
'color_interior_code','color_interior_text',
'options_official','features','emission_std_code',
'origin_country','currency','msrp',
'is_active','corp_id','remark',
'created_by','updated_by','created_at','updated_at',
];
/** 默认剔除:技术/循环依赖字段 */
public const EXCLUDE_DEFAULT = [
'id','sku_code','fingerprint', 'dimensions_mm', 'options_official', 'features',
'origin_country','currency','msrp',
'is_active','corp_id','remark',
'created_by','updated_by','created_at','updated_at'
];
/** 识别为“code类”的字段模式(统一大写) */
private const CODE_FIELD_REGEX = '/(_code|_country|^currency)$/i';
/** 识别为 JSON 字段 */
private const JSON_FIELDS = ['dimensions_mm','options_official','features'];
/**
* 从 Eloquent 模型生成
*/
public static function makeFromModel($model, array $opts = []): string
{
return self::makeFromArray($model->toArray(), $opts);
}
/**
* 从数组生成指纹
* @param array $row 一般为 $_POST 合并后的业务数据或 $model->toArray()
* @param array $opts ['exclude'=>[], 'extra'=>[], 'salt'=>string]
* - exclude: 额外排除的字段(默认仅排除 id,fingerprint)
* - extra : 额外拼入的键值(例如上下文、租户策略),会参与计算
* - salt : 额外加盐字符串
*/
public static function makeFromArray(array $row, array $opts = []): string
{
$exclude = array_values(array_unique(array_merge(self::EXCLUDE_DEFAULT, $opts['exclude'] ?? [])));
$extra = $opts['extra'] ?? [];
$salt = $opts['salt'] ?? '';
// 1) 统一抽取“表中定义的字段” + extra,缺失字段也要纳入(值按 NULL 处理)
$data = [];
foreach (self::COLS as $col) {
if (in_array($col, $exclude, true)) continue;
$data[$col] = array_key_exists($col, $row) ? $row[$col] : null;
}
// extra 也参与
foreach ($extra as $k => $v) {
if (!is_string($k) || $k === '') continue;
$data['__extra__'.$k] = $v;
}
// 2) 规范化(按字段特征)
$canon = [];
foreach ($data as $k => $v) {
$canon[$k] = self::normalize($k, $v);
}
// 3) 键名排序,确保顺序无关
ksort($canon, SORT_STRING);
// 4) 拼成稳定串: key=value | key=value...
$parts = [];
foreach ($canon as $k => $v) {
// 为空也必须占位,避免 key 消失影响其他键的意义
$parts[] = $k.'='.$v;
}
if ($salt !== '') {
$parts[] = '__salt__='.$salt;
}
$plain = implode('|', $parts);
// 5) sha256
return hash('sha256', $plain);
}
/**
* 规范化单个字段
* - NULL -> 特殊占位 "∅"(避免与空串混淆)
* - 布尔/整数/浮点 -> 统一数字字符串
* - code类字段 -> 去首尾空白 & 转大写
* - 普通字符串 -> trim & 压缩连续空白为单空格
* - JSON字段 -> 解析后做“键排序 + 值规范化 + 索引数组可排序”,再无空白编码
*/
private static function normalize(string $field, $value): string
{
if ($value === null) return '∅';
// 数值类型
if (is_int($value)) return (string)$value;
if (is_bool($value)) return $value ? '1' : '0';
if (is_float($value) || (is_string($value) && is_numeric($value) && self::looksLikeNumberField($field))) {
// 统一浮点格式,最多 6 位小数并去尾零
$num = (float)$value;
$s = number_format($num, 6, '.', '');
$s = rtrim(rtrim($s, '0'), '.');
return $s === '' ? '0' : $s;
}
// JSON 字段
if (in_array($field, self::JSON_FIELDS, true)) {
return self::normalizeJson($value);
}
// 字符串
if (is_string($value)) {
$s = trim($value);
// code类:统一大写
if (preg_match(self::CODE_FIELD_REGEX, $field)) {
$s = mb_strtoupper($s, 'UTF-8');
} else {
// 普通文本:压缩连续空白
$s = preg_replace('/\s+/u', ' ', $s);
}
return $s === '' ? '' : $s;
}
// 数组(极少出现,按 JSON 处理)
if (is_array($value)) {
return self::normalizeJson($value);
}
// 其他类型(对象等):转 JSON 后处理
return self::normalizeJson($value);
}
/** 判断这个字段是否“看起来像数值字段” */
private static function looksLikeNumberField(string $field): bool
{
static $numKeys = [
'engine_disp_l','engine_power_kw','battery_kwh','gears',
'range_km','doors','seats','weight_kg','msrp','model_year',
'created_at','updated_at','corp_id','is_active'
];
return in_array($field, $numKeys, true);
}
/**
* JSON 规范化:
* - 字符串:若能 json_decode 则按 JSON;否则当普通字符串再规范化
* - 对象:键名排序;值递归规范化
* - 索引数组:元素递归规范化后,按字符串值排序(视为集合,避免顺序抖动)
* - 最终 json_encode(JSON_UNESCAPED_UNICODE|UNESCAPED_SLASHES)
*/
private static function normalizeJson($val): string
{
// 已是数组/对象
if (is_array($val)) {
$normalized = self::canonArray($val);
return json_encode($normalized, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);
}
// 字符串尝试解析
if (is_string($val)) {
$val = trim($val);
if ($val === '') return '""';
$decoded = json_decode($val, true);
if (json_last_error() === JSON_ERROR_NONE) {
$normalized = self::canonArray($decoded);
return json_encode($normalized, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);
}
// 非 JSON 字符串,按普通字符串规范化
$s = preg_replace('/\s+/u', ' ', $val);
return json_encode($s, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);
}
// 其他类型:直接编码
return json_encode($val, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);
}
/** 递归规范化数组/对象 */
private static function canonArray($arr)
{
if (!is_array($arr)) return $arr;
// 关联数组(对象)
if (self::isAssoc($arr)) {
$out = [];
ksort($arr, SORT_STRING);
foreach ($arr as $k => $v) {
$out[(string)$k] = self::canonValue($v);
}
return $out;
}
// 索引数组(当集合处理:元素字符串化后排序)
$tmp = [];
foreach ($arr as $v) {
$tmp[] = self::canonValue($v);
}
// 将元素转为字符串再排序,确保稳定
usort($tmp, function($a, $b) {
$sa = is_scalar($a) ? (string)$a : json_encode($a, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);
$sb = is_scalar($b) ? (string)$b : json_encode($b, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);
return strcmp($sa, $sb);
});
return $tmp;
}
private static function canonValue($v)
{
if (is_array($v)) return self::canonArray($v);
if (is_bool($v)) return $v ? 1 : 0;
if (is_int($v)) return $v;
if (is_float($v)) {
$s = number_format($v, 6, '.', '');
$s = rtrim(rtrim($s, '0'), '.');
return $s === '' ? '0' : $s;
}
if (is_string($v)) {
$s = trim($v);
$s = preg_replace('/\s+/u', ' ', $s);
return $s;
}
return $v;
}
private static function isAssoc(array $arr): bool
{
// 有任意一个键不是 0..n 的递增整数,就视为关联数组
$i = 0;
foreach ($arr as $k => $_) {
if ($k !== $i++) return true;
}
return false;
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace app\common\logic;
final class ModelNaming
{
/** 生成稳定的 model_code(只用 *_code 与关键数字) */
public static function buildModelCode(array $s): string
{
$brand = self::tok($s['brand_code'] ?? '');
$series = self::tok($s['series_code'] ?? '');
$fuel = self::tok($s['fuel_type_code'] ?? 'UNK');
$parts = [$brand, $series];
$key = [];
switch ($fuel) {
case 'EV':
$key[] = 'EV';
if (!empty($s['range_km'])) {
$key[] = (string) (round((int)$s['range_km'] / 10) * 10); // 到十位
} elseif (!empty($s['battery_kwh'])) {
$key[] = self::numToken($s['battery_kwh']) . 'KWH';
}
break;
case 'GAS':
if (!empty($s['engine_disp_l'])) $key[] = self::dispToken($s['engine_disp_l']); // 1.5L -> 15L
if (!empty($s['engine_turbo']) && (int)$s['engine_turbo'] === 1) $key[] = 'T';
if (!empty($s['transmission_code'])) $key[] = self::tok($s['transmission_code']);
break;
case 'HEV':
case 'PHEV':
case 'EREV':
$key[] = $fuel;
if (!empty($s['engine_disp_l'])) $key[] = self::dispToken($s['engine_disp_l']);
if (!empty($s['motor_layout_code'])) $key[] = self::tok($s['motor_layout_code']); // 如 DM-I/DM-P/AWD
break;
default:
$key[] = $fuel;
}
if (!empty($s['drive_type_code'])) $key[] = self::tok($s['drive_type_code']);
$parts[] = implode('_', array_filter($key));
return strtoupper(implode('_', array_filter($parts)));
}
/** 生成中文可读的 model_name(走快照中文,不依赖 i18n) */
public static function buildModelName(array $s): string
{
$seriesName = trim((string)($s['series_name'] ?? $s['series_code'] ?? ''));
$yearPart = !empty($s['model_year']) ? ($s['model_year'] . '款 ') : '';
$fuel = strtoupper($s['fuel_type_code'] ?? '');
$tail = '';
if ($fuel === 'EV') {
$range = !empty($s['range_km']) ? (int)$s['range_km'] . 'KM ' : '';
$drive = trim((string)($s['drive_type'] ?? $s['drive_type_code'] ?? ''));
$tail = "EV {$range}{$drive}";
} elseif ($fuel === 'GAS') {
$disp = (!empty($s['engine_disp_l']))
? rtrim(rtrim(number_format((float)$s['engine_disp_l'], 1, '.', ''), '0'), '.') . 'L '
: '';
$trans = trim((string)($s['transmission'] ?? $s['transmission_code'] ?? ''));
$tail = "{$disp}{$trans}";
} else {
$tail = trim((string)($s['fuel_type'] ?? $fuel));
}
$extras = [];
foreach (['trim','trim_level','edition_name'] as $k) {
if (!empty($s[$k])) $extras[] = trim((string)$s[$k]);
}
$extraStr = $extras ? ' ' . implode(' ', $extras) : '';
return trim($seriesName . ' ' . $yearPart . $tail . $extraStr);
}
/* ===== Helpers ===== */
private static function tok($v): string
{
$v = strtoupper((string)$v);
return preg_replace('/[^A-Z0-9_]/', '', $v ?? '');
}
private static function numToken($v): string
{
return preg_replace('/[^0-9]/', '', (string)$v);
}
/** 1.5L -> 15L;2.0L -> 20L(便于 code 稳定检索) */
private static function dispToken($l): string
{
$x = (float)$l;
return (string)round($x * 10) . 'L';
}
}
+233
View File
@@ -0,0 +1,233 @@
<?php
namespace app\common\logic;
use app\common\model\VehVehicleSku;
/**
* SKU 生成器
* - 输入: 车辆规格数组(以 *_code 为主)
* - 输出: 稳定、人读友好的 sku_code (<= 40 默认)
*/
final class SkuCodeBuilder
{
/** 压缩映射(可按需扩充) */
private const ABBR = [
// level
'FLAGSHIP' => 'FLG',
'PREMIUM' => 'PRM',
'STANDARD' => 'STD',
'DELUXE' => 'DLX',
// edition
'HONOR' => 'HNR',
'CHAMPION' => 'CHP',
'ANNIV' => 'ANV',
// trans
'1SPEED' => '1SP',
// drive/fuel/body 等一般已短,不必压缩
];
/** 入口:生成 sku_code */
public static function build(array $s, array $opts = []): string
{
$maxLen = $opts['max_len'] ?? 40;
$ensureUnique = $opts['ensure_unique'] ?? true;
$tokens = [
'brand' => self::tok($s['brand_code'] ?? ''),
'series' => self::tok($s['series_code'] ?? ''),
'year' => isset($s['model_year']) ? (string)intval($s['model_year']) : '',
'fuel' => self::tok($s['fuel_type_code'] ?? ''),
'drive' => self::tok($s['drive_type_code'] ?? ''),
'spec' => self::buildSpec($s, self::tok($s['fuel_type_code'] ?? '')),
'level' => self::abbr(self::tok($s['trim_level_code'] ?? '')),
'edition' => self::abbr(self::tok($s['edition_code'] ?? '')),
];
// 过滤空段并按既定顺序拼接
$order = ['brand','series','year','fuel','drive','spec','level','edition'];
$parts = [];
foreach ($order as $k) {
if (!empty($tokens[$k])) $parts[$k] = $tokens[$k];
}
$sku = self::normalizeSku(implode('-', $parts));
if (strlen($sku) > $maxLen) {
$sku = self::shrink($parts, $maxLen); // 传入“带键名的 parts”
}
if ($ensureUnique) {
$sku = self::ensureUnique($sku, $maxLen);
}
return $sku;
}
/** 构造规格位(SPEC),按能源类型差异化 */
private static function buildSpec(array $s, string $fuel): string
{
$fuel = strtoupper($fuel);
if ($fuel === 'EV') {
if (!empty($s['range_km'])) {
$r = (int)$s['range_km'];
$r10 = (int)round($r / 10) * 10; // 到 10 位
return (string)$r10;
}
if (!empty($s['battery_kwh'])) {
return self::numToken($s['battery_kwh']) . 'KWH';
}
return '';
}
if ($fuel === 'GAS') {
$bits = [];
if (!empty($s['engine_disp_l'])) {
$bits[] = self::dispToken($s['engine_disp_l']); // 1.5L -> 15L
}
if (!empty($s['engine_turbo']) && intval($s['engine_turbo']) === 1) {
$bits[] = 'T';
}
if (!empty($s['transmission_code'])) {
$bits[] = self::abbr(self::tok($s['transmission_code']));
}
return implode('', $bits); // 如 15LTCVT
}
// HEV/PHEV/EREV 等
$bits = [];
if (!empty($s['engine_disp_l'])) {
$bits[] = self::dispToken($s['engine_disp_l']); // 可空
}
if (!empty($s['motor_layout_code'])) {
$bits[] = self::tok($s['motor_layout_code']); // 如 AWD / DM-I / DM-P
}
return implode('', $bits);
}
/** 冲突检测并追加短校验(全局唯一) */
private static function ensureUnique(string $sku, int $maxLen): string
{
if (!VehVehicleSku::where('sku_code', $sku)->exists()) {
return $sku;
}
// 用 crc32 生成 5~6 位 base36 校验(稳定且短)
$crc = self::base36Crc($sku);
$suffix = '-X' . $crc;
if (strlen($sku . $suffix) > $maxLen) {
$sku = substr($sku, 0, max(0, $maxLen - strlen($suffix)));
$sku = rtrim($sku, '-'); // 避免尾部 '-'
}
$sku .= $suffix;
// 若极端情况下仍冲突,追加递增计数
$i = 2;
$candidate = $sku;
while (VehVehicleSku::where('sku_code', $candidate)->exists()) {
$extra = '-'.$i;
$cut = max(0, $maxLen - strlen($extra));
$candidate = substr($sku, 0, $cut) . $extra;
$i++;
}
return $candidate;
}
/** 把 token 数组尽量压到 maxLen(逐步压缩策略) */
private static function shrink(array $partsByKey, int $maxLen): string
{
// 1) 先对 series/level/edition 做激进压缩
$p = $partsByKey;
foreach (['series','level','edition'] as $k) {
if (!empty($p[$k])) $p[$k] = self::heavyAbbr($p[$k]);
}
$sku = self::normalizeSku(implode('-', $p));
if (strlen($sku) <= $maxLen) return $sku;
// 2) 依次移除不太关键的信息:edition → level(若存在)
if (!empty($p['edition'])) { unset($p['edition']); }
$sku = self::normalizeSku(implode('-', $p));
if (strlen($sku) <= $maxLen) return $sku;
if (!empty($p['level'])) { unset($p['level']); }
$sku = self::normalizeSku(implode('-', $p));
if (strlen($sku) <= $maxLen) return $sku;
// 3) 压缩 spec(如去 KM/KWH,仅保留核心)
if (!empty($p['spec'])) { $p['spec'] = self::compactSpec($p['spec']); }
$sku = self::normalizeSku(implode('-', $p));
if (strlen($sku) <= $maxLen) return $sku;
// 4) 仍超长:从右向左截断次要段(spec/drive/year),保留前三段(brand/series/fuel)优先
$dropOrder = ['spec','drive','year'];
$q = $p;
foreach ($dropOrder as $k) {
if (!empty($q[$k])) { unset($q[$k]); }
$sku = self::normalizeSku(implode('-', $q));
if (strlen($sku) <= $maxLen) return $sku;
}
// 5) 最后兜底:硬截到 maxLen,后续 ensureUnique 会追加 -X 校验并避免冲突
return substr(self::normalizeSku(implode('-', $p)), 0, $maxLen);
}
/* ===== Helpers ===== */
private static function tok($v): string
{
$v = strtoupper((string)$v);
return preg_replace('/[^A-Z0-9_]/', '', $v ?? '');
}
private static function abbr(string $t): string
{
return self::ABBR[$t] ?? $t;
}
private static function heavyAbbr(string $t): string
{
// 更激进:去除元音,连字符化
$t = str_replace(['STANDARD','FLAGSHIP','PREMIUM','DELUXE'], ['STD','FLG','PRM','DLX'], $t);
$t = preg_replace('/[AEIOU]/', '', $t);
return $t;
}
private static function compactSpec(string $s): string
{
// 规则:去 KM/KWH 等单位,只保留数字与核心字母
$s = strtoupper($s);
$s = str_replace(['KM','KWH'], '', $s);
return $s;
}
private static function numToken($v): string
{
return preg_replace('/[^0-9]/', '', (string)$v);
}
/** 1.5L -> 15L;2.0L -> 20L */
private static function dispToken($l): string
{
$x = (float)$l;
return (string)round($x * 10) . 'L';
}
private static function base36Crc(string $s): string
{
$crc = sprintf('%u', crc32($s)); // 无符号
$n = (int)$crc;
$chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
$res = '';
do {
$res = $chars[$n % 36] . $res;
$n = intdiv($n, 36);
} while ($n > 0);
return substr($res, -6); // 取后6位足够
}
private static function normalizeSku(string $s): string
{
// 连续的 '-' 合并,首尾修剪
$s = preg_replace('/-+/', '-', $s);
$s = trim($s, '-');
return $s;
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace app\common\logic;
use app\common\traits\crud\CrudLogicTrait;
use app\common\model\VehDictI18n;
class VehDictI18nLogic
{
use CrudLogicTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = VehDictI18n::class;
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace app\common\logic;
use app\common\traits\crud\CrudLogicTrait;
use app\common\model\VehDictMapping;
class VehDictMappingLogic
{
use CrudLogicTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = VehDictMapping::class;
}
@@ -0,0 +1,17 @@
<?php
namespace app\common\logic;
use app\common\traits\crud\CrudLogicTrait;
use app\common\model\VehVehicleListing;
class VehVehicleListingLogic
{
use CrudLogicTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = VehVehicleListing::class;
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace app\common\logic;
use app\common\traits\crud\CrudLogicTrait;
use app\common\model\VehVehicleSku;
class VehVehicleSkuLogic
{
use CrudLogicTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = VehVehicleSku::class;
/**
* 指纹 = “稳定可复现的事实串”做 sha256
* - 仅纳入稳定维度:品牌/车系/年款/能源/驱动/变速/车身 + 关键数值
* - 不纳入营销名(trim/edition)、颜色文本等
* - corp 维度:若你未来希望租户内去重,可传 $scopeCorp=true
*/
public static function fingerprintMake(array $sku, bool $scopeCorp = false): string
{
$norm = function ($v) {
if ($v === null || $v === '') return '';
if (is_float($v)) return rtrim(rtrim(number_format($v, 3, '.', ''), '0'), '.'); // 规范小数
return (string)$v;
};
$parts = [
$scopeCorp ? ($sku['corp_id'] ?? 0) : '',
strtoupper($sku['brand_code'] ?? ''),
strtoupper($sku['series_code'] ?? ''),
(int)($sku['model_year'] ?? 0),
strtoupper($sku['fuel_type_code'] ?? ''),
strtoupper($sku['drive_type_code']?? ''),
strtoupper($sku['transmission_code'] ?? ''),
strtoupper($sku['body_type_code'] ?? ''),
$norm($sku['engine_disp_l'] ?? null),
(int)($sku['engine_turbo'] ?? 0),
$norm($sku['battery_kwh'] ?? null),
(int)($sku['range_km'] ?? 0),
strtoupper($sku['range_std_code'] ?? ''),
strtoupper($sku['emission_std_code'] ?? ''),
];
$plain = implode('|', $parts);
return hash('sha256', $plain);
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace app\common\logic;
use app\common\traits\crud\CrudLogicTrait;
use app\common\model\VehVehicleUnit;
class VehVehicleUnitLogic
{
use CrudLogicTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = VehVehicleUnit::class;
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace app\common\model;
use support\Model;
class VehDictI18n extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'veh_dict_i18n';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = ['id', 'dict', 'code', 'locale', 'label', 'status', 'remark', 'created_by', 'updated_by', 'created_at', 'updated_at'];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace app\common\model;
use support\Model;
class VehDictMapping extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'veh_dict_mapping';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $dateFormat = 'U';
protected $casts = [
'required' => 'integer',
'enabled' => 'integer',
'sort_order' => 'integer',
'created_at' => 'integer',
'updated_at' => 'integer',
];
protected $fillable = [
'field','dict','required','enabled','sort_order',
'remark','created_at','updated_at'
];
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace app\common\model;
use support\Model;
class VehVehicleListing extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'veh_vehicle_listing';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = ['id', 'sku_id', 'unit_id', 'title', 'sub_title', 'lang', 'market', 'channel', 'currency', 'list_price', 'promo_price', 'vat_included', 'lead_time_days', 'cover_image', 'media', 'attrs_override', 'status', 'visible_from', 'visible_to', 'corp_id', 'sort', 'remark', 'created_by', 'updated_by', 'created_at', 'updated_at'];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
}
+81
View File
@@ -0,0 +1,81 @@
<?php
namespace app\common\model;
use support\Model;
class VehVehicleSku extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'veh_vehicle_sku';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $dateFormat = 'U';
protected $casts = [
'options_official' => 'array',
'features' => 'array',
'dimensions_mm' => 'array',
'model_year' => 'integer',
'engine_turbo' => 'integer',
'gears' => 'integer',
'range_km' => 'integer',
'doors' => 'integer',
'seats' => 'integer',
'weight_kg' => 'integer',
'msrp' => 'float',
'battery_kwh' => 'float',
'engine_disp_l' => 'float',
'is_active' => 'integer',
'corp_id' => 'integer',
'created_at' => 'integer',
'updated_at' => 'integer',
];
protected $fillable = [
'sku_code','fingerprint',
'brand_code','brand_name',
'series_code','series_name',
'model_code','model_name','model_year',
'trim_code','trim_level_code','edition_code',
'trim','trim_level','edition_name',
'body_type_code','drive_type_code','motor_layout_code',
'fuel_type_code','engine_disp_l','engine_turbo','engine_power_kw',
'battery_kwh','transmission_code','gears',
'range_km','range_std_code',
'doors','seats','weight_kg','dimensions_mm',
'color_exterior_code','color_exterior_text',
'color_interior_code','color_interior_text',
'options_official','features','emission_std_code',
'origin_country','currency','msrp',
'is_active','corp_id','remark',
'created_by','updated_by','created_at','updated_at',
'body_type', 'drive_type', 'motor_layout', 'fuel_type', 'transmission',
'range_std', 'emission_std'
];
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace app\common\model;
use support\Model;
class VehVehicleUnit extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'veh_vehicle_unit';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = ['id', 'sku_id', 'vin', 'engine_no', 'license_plate', 'first_reg_date', 'ownership_count', 'odometer', 'odometer_unit', 'condition_grade', 'accident_record', 'paint_count', 'owner_type', 'import_status', 'location_id', 'location_text', 'images', 'docs', 'stock_status', 'cost_total', 'currency', 'is_active', 'corp_id', 'remark', 'created_by', 'updated_by', 'created_at', 'updated_at'];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
}
+75
View File
@@ -0,0 +1,75 @@
<?php
namespace app\common\service;
use app\common\validate\VehDictI18nValidate;
use app\common\traits\crud\CrudServiceTrait;
use app\common\model\VehDictI18n;
class VehDictI18nService
{
use CrudServiceTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = VehDictI18n::class;
/**
* 验证器
*/
protected $validator = VehDictI18nValidate::class;
/**
* 获取验证器
*/
public function getValidator()
{
return new $this->validator;
}
/** 本进程缓存:[dict][code] => labels array */
protected static array $labelsCache = [];
public static function label(string $dict=null, ?string $code=null, string $locale='zh-CN', ?string $fallbackZh=null): ?string
{
if (!$dict || !$code) return $fallbackZh;
$labels = self::labels($dict, $code);
if (!$labels) return $fallbackZh ?? $code;
return $labels[$locale] ?? $labels['zh-CN'] ?? $code;
}
/** 返回 labels 数组,命中缓存 */
public static function labels(string $dict, string $code): ?array
{
$dict = strtoupper($dict);
$code = (string)$code;
if (isset(self::$labelsCache[$dict][$code])) {
return self::$labelsCache[$dict][$code];
}
$term = DictTerm::where('dict',$dict)->where('code',$code)->first(['labels']);
if (!$term) return null;
$labels = $term->labels ?? null;
if (!is_array($labels)) return null;
self::$labelsCache[$dict][$code] = $labels;
return $labels;
}
/** 校验 code 是否存在于字典(用于必填维度) */
public static function ensureExists(string $dict, ?string $code, string $fieldNameForError): void
{
if ($code === null || $code === '') {
throw new \InvalidArgumentException("缺少必填字段:{$fieldNameForError}");
}
$ok = VehDictI18n::where('dict',$dict)->where('code',$code)->where('status', 1)->exists();
if (!$ok) throw new \InvalidArgumentException("字典不存在或未启用:{$dict}.{$code}");
}
}
@@ -0,0 +1,57 @@
<?php
namespace app\common\service;
use app\common\validate\VehDictMappingValidate;
use app\common\traits\crud\CrudServiceTrait;
use app\common\model\VehDictMapping;
class VehDictMappingService
{
use CrudServiceTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = VehDictMapping::class;
/**
* 验证器
*/
protected $validator = VehDictMappingValidate::class;
/**
* 获取验证器
*/
public function getValidator()
{
return new $this->validator;
}
public static function all(): array
{
$rows = VehDictMapping::where('status', 1)->orderBy('sort')->orderBy('id')->get()->toArray();
$out = [];
foreach ($rows as $r) {
$out[$r['dict']] = [
'field' => $r['field'],
'dict' => $r['dict'],
'required' => (int)$r['required'] === 1,
];
}
return $out;
}
public static function requiredDicts(): array
{
return array_keys(array_filter(self::all(), fn($x)=>$x['required']===true));
}
public static function enabled(string $dict): bool
{
$all = self::all();
return isset($all[$dict]);
}
}
@@ -0,0 +1,31 @@
<?php
namespace app\common\service;
use app\common\validate\VehVehicleListingValidate;
use app\common\traits\crud\CrudServiceTrait;
use app\common\model\VehVehicleListing;
class VehVehicleListingService
{
use CrudServiceTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = VehVehicleListing::class;
/**
* 验证器
*/
protected $validator = VehVehicleListingValidate::class;
/**
* 获取验证器
*/
public function getValidator()
{
return new $this->validator;
}
}
+349
View File
@@ -0,0 +1,349 @@
<?php
namespace app\common\service;
use app\common\logic\Fingerprint;
use app\common\logic\ModelNaming;
use app\common\logic\SkuCodeBuilder;
use app\common\validate\VehVehicleSkuValidate;
use app\common\traits\crud\CrudServiceTrait;
use app\common\model\VehVehicleSku;
use support\Request;
class VehVehicleSkuService
{
use CrudServiceTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = VehVehicleSku::class;
/**
* 验证器
*/
protected $validator = VehVehicleSkuValidate::class;
/**
* 获取验证器
*/
public function getValidator()
{
return new $this->validator;
}
// 枚举
private const MAPS = [
'BODY' => [
'SEDAN' => '三厢轿车', 'SUV'=>'SUV', 'HB'=>'两厢/掀背', 'MPV'=>'MPV', 'PICKUP'=>'皮卡'
],
'DRIVE' => [
'FWD'=>'前驱', 'RWD'=>'后驱', 'AWD'=>'四驱(AWD)', '4WD'=>'四驱(4WD)'
],
'MOTOR_LAYOUT' => [
'FWD'=>'前置单电机', 'RWD'=>'后置单电机', 'AWD'=>'双电机四驱', 'DM-I'=>'DM-i', 'DM-P'=>'DM-p'
],
'FUEL' => [
'GAS'=>'汽油', 'HEV'=>'油电混动', 'PHEV'=>'插电混动', 'EV'=>'纯电', 'EREV'=>'增程'
],
'TRANS' => [
'MT'=>'手动', 'AT'=>'自动', 'CVT'=>'无级', 'DCT'=>'双离合', '1SPEED'=>'单速'
],
'RANGE_STD' => [
'CLTC'=>'CLTC', 'WLTP'=>'WLTP', 'EPA'=>'EPA', 'NEDC'=>'NEDC'
],
'EMISSION' => [
'CHN6'=>'国六', 'EURO6'=>'欧6', 'EPA'=>'美国EPA'
],
];
// *_code取中文
public function label(string $dict, ?string $code): ?string
{
if ($code === null || $code === '') return null;
$dict = strtoupper($dict);
$code = strtoupper($code);
return self::MAPS[$dict][$code] ?? $code;
}
// 静态映射把 *_code → 中文
private function backfill(array $d): array
{
$d['body_type'] = $this->label('BODY', $d['body_type_code']);
$d['drive_type'] = $this->label('DRIVE', $d['drive_type_code']);
$d['motor_layout'] = $this->label('MOTOR_LAYOUT', $d['motor_layout_code']);
$d['fuel_type'] = $this->label('FUEL', $d['fuel_type_code']);
$d['transmission'] = $this->label('TRANS', $d['transmission_code']);
$d['range_std'] = $this->label('RANGE_STD', $d['range_std_code']);
$d['emission_std'] = $this->label('EMISSION', $d['emission_std_code']);
return $d;
}
protected function buildQueryConfig(Request $request, string $scene = 'default'): array
{
$p = $request->all();
$config = [];
$config['model'] = $this->modelClass;
switch ($scene) {
case 'default':
$config['where']['is_active'] = 1;
$config['order']['sort'] = 'desc';
$config['order']['id'] = 'desc';
break;
case 'index':
$config['where']['is_active'] = 1;
$config['order']['sort'] = 'desc';
$config['order']['id'] = 'desc';
break;
case 'show':
$config['where']['id'] = $p['id'];
$config['where']['is_active'] = 1;
break;
case 'store':
break;
case 'update':
$config['where']['id'] = $p['id'];
break;
case 'destroy':
$config['where']['id'] = $p['id'];
break;
}
return $config;
}
public function beforeServiceStore($data, Request $request)
{
$data = $this->backfill($data);
// 未提供时自动生成;若你想“强制覆盖人工输入”,可去掉 isset 判断
if (empty($data['model_code'])) {
$data['model_code'] = ModelNaming::buildModelCode($data);
}
if (empty($data['model_name'])) {
$data['model_name'] = ModelNaming::buildModelName($data);
}
// 填充系统字段
$data['sku_code'] = SkuCodeBuilder::build($data);
$data['corp_id'] = 0;
$data['created_by'] = $request->user->uid;
$data['updated_by'] = $request->user->uid;
// 指纹
$data['fingerprint'] = Fingerprint::makeFromArray($data);
return $data;
}
protected function afterServiceStore($res)
{
return $res;
}
public function beforeServiceUpdate($data, Request $request)
{
$data = $this->backfill($data);
// 未提供时自动生成;若你想“强制覆盖人工输入”,可去掉 isset 判断
if (empty($data['model_code'])) {
$data['model_code'] = ModelNaming::buildModelCode($data);
}
if (empty($data['model_name'])) {
$data['model_name'] = ModelNaming::buildModelName($data);
}
// 填充系统字段
$data['updated_by'] = $request->user->uid;
// 指纹
$data['fingerprint'] = Fingerprint::makeFromArray($data);
return $data;
}
protected function afterServiceUpdate($res)
{
return $res;
}
/** SKU 字段名 → dict 名(用于 i18n 取词 & 必填校验) */
protected static array $SKU_FIELD_TO_DICT = [
'brand_code' => 'BRAND',
'series_code' => 'SERIES',
'model_code' => 'MODEL',
'trim_code' => 'TRIM',
'trim_level_code' => 'TRIM_LEVEL',
'edition_code' => 'EDITION',
'fuel_type_code' => 'FUEL',
'drive_type_code' => 'DRIVE',
'transmission_code' => 'TRANS',
'motor_layout_code' => 'MOTOR_LAYOUT',
'body_type_code' => 'BODY',
'range_std_code' => 'RANGE_STD',
'emission_std_code' => 'EMISSION',
'color_exterior_code'=> 'COLOR',
'color_interior_code'=> 'COLOR',
];
/** 创建 SKU(含指纹与字典校验) */
public static function create(array $p, int $userId=0, int $corpId=0): VehicleSku
{
// SkuValidator::validateForCreate($p);
// 必填字典:由映射表控制(一般 BRAND/SERIES/FUEL)
$requiredDicts = MappingService::requiredDicts();
foreach (self::$SKU_FIELD_TO_DICT as $field => $dict) {
if (!MappingService::enabled($dict)) continue;
$isRequired = in_array($dict, $requiredDicts, true);
if ($isRequired) {
DictService::ensureExists($dict, $p[$field] ?? null, $field);
}
}
// 填充系统字段
$now = time();
$p['sku_code'] = $p['sku_code'] ?? ('SKU'.date('Ymd').'-'.substr(bin2hex(random_bytes(3)),0,6));
$p['corp_id'] = $p['corp_id'] ?? $corpId;
$p['created_by'] = $p['created_by'] ?? $userId;
$p['updated_by'] = $p['updated_by'] ?? $userId;
$p['created_at'] = $p['created_at'] ?? $now;
$p['updated_at'] = $p['updated_at'] ?? $now;
// 指纹
$p['fingerprint'] = Fingerprint::make($p /*, $scopeCorp=true/false*/);
return DB::connection()->transaction(function () use ($p) {
$sku = new VehicleSku($p);
$sku->save();
return $sku;
});
}
/** 更新 SKU(部分字段;指纹随事实变动而刷新) */
public static function update(int $id, array $p, int $userId=0): VehicleSku
{
SkuValidator::validateForUpdate($p);
$sku = VehicleSku::findOrFail($id);
// 若传入了必填 dict 的 code,需要确认字典存在
$requiredDicts = MappingService::requiredDicts();
foreach (self::$SKU_FIELD_TO_DICT as $field => $dict) {
if (!array_key_exists($field, $p)) continue;
if (!MappingService::enabled($dict)) continue;
$isRequired = in_array($dict, $requiredDicts, true);
if ($isRequired) {
DictService::ensureExists($dict, $p[$field], $field);
} else {
// 可选的:若传了 code 则也校验存在
if (($p[$field] ?? null) !== null && $p[$field] !== '') {
$exists = \app\model\DictTerm::where('dict',$dict)->where('code',$p[$field])->exists();
if (!$exists) throw new \InvalidArgumentException("字典不存在:{$dict}.{$p[$field]}");
}
}
}
// 刷新系统字段
$p['updated_by'] = $userId;
$p['updated_at'] = time();
// 赋值保存
$sku->fill($p);
// 只要影响到指纹的事实字段变动,就重算指纹
$fingerFields = [
'brand_code','series_code','model_year','fuel_type_code',
'drive_type_code','transmission_code','body_type_code',
'engine_disp_l','engine_turbo','battery_kwh','range_km',
'range_std_code','emission_std_code',
'corp_id'
];
foreach ($fingerFields as $f) {
if (array_key_exists($f, $p)) {
$sku->fingerprint = Fingerprint::make($sku->toArray());
break;
}
}
$sku->save();
return $sku;
}
/** 详情(带 i18n label 与标准标题) */
public static function detail(int $id, string $locale='zh-CN'): array
{
$sku = VehicleSku::findOrFail($id);
$data = $sku->toArray();
$labelOf = function(string $field) use ($data, $locale) {
$dict = self::$SKU_FIELD_TO_DICT[$field] ?? null;
if (!$dict) return null;
$code = $data[$field] ?? null;
// 若是品牌/车系,允许以中文兜底
$fallback = null;
if ($field === 'brand_code') $fallback = $data['brand_name'] ?? null;
if ($field === 'series_code') $fallback = $data['series_name'] ?? null;
// 版型/等级/特别版允许中文兜底
if ($field === 'trim_code') $fallback = $data['trim'] ?? null;
if ($field === 'trim_level_code') $fallback = $data['trim_level'] ?? null;
if ($field === 'edition_code') $fallback = $data['edition_name'] ?? null;
// 颜色兜底中文
if ($field === 'color_exterior_code') $fallback = $data['color_exterior_text'] ?? null;
if ($field === 'color_interior_code') $fallback = $data['color_interior_text'] ?? null;
return \app\service\DictService::label($dict, $code, $locale, $fallback);
};
$i18n = [
'brand_label' => $labelOf('brand_code'),
'series_label' => $labelOf('series_code'),
'model_label' => \app\service\DictService::label('MODEL', $data['model_code'] ?? null, $locale, $data['model_name'] ?? null),
'trim_label' => $labelOf('trim_code'),
'trim_level_label' => $labelOf('trim_level_code'),
'edition_label' => $labelOf('edition_code'),
'fuel_label' => $labelOf('fuel_type_code'),
'drive_label' => $labelOf('drive_type_code'),
'trans_label' => $labelOf('transmission_code'),
'motor_layout_label' => $labelOf('motor_layout_code'),
'body_label' => $labelOf('body_type_code'),
'range_std_label' => $labelOf('range_std_code'),
'emission_label' => $labelOf('emission_std_code'),
'color_exterior_label' => $labelOf('color_exterior_code'),
'color_interior_label' => $labelOf('color_interior_code'),
'listing_title' => TitleBuilder::build($data, $locale),
];
return ['sku'=>$data, 'i18n'=>$i18n];
}
/** 列表查询(支持基础筛选 & i18n 列) */
public static function list(array $q, string $locale='zh-CN', int $page=1, int $pageSize=20): array
{
$builder = VehicleSku::query();
// 常用筛选
if (!empty($q['brand_code'])) $builder->where('brand_code', $q['brand_code']);
if (!empty($q['series_code'])) $builder->where('series_code', $q['series_code']);
if (!empty($q['fuel_type_code'])) $builder->where('fuel_type_code', $q['fuel_type_code']);
if (!empty($q['model_year'])) $builder->where('model_year', (int)$q['model_year']);
if (isset($q['is_active'])) $builder->where('is_active', (int)$q['is_active']);
$builder->orderByDesc('created_at');
$total = (clone $builder)->count();
$rows = $builder->forPage($page, $pageSize)->get()->toArray();
// 附加 i18n 字段(轻量)
foreach ($rows as &$row) {
$row['brand_label'] = DictService::label('BRAND', $row['brand_code'] ?? null, $locale, $row['brand_name'] ?? null);
$row['series_label'] = DictService::label('SERIES', $row['series_code'] ?? null, $locale, $row['series_name'] ?? null);
$row['fuel_label'] = DictService::label('FUEL', $row['fuel_type_code'] ?? null, $locale, null);
$row['listing_title']= TitleBuilder::build($row, $locale);
}
return ['total'=>$total, 'list'=>$rows, 'page'=>$page, 'page_size'=>$pageSize];
}
}
@@ -0,0 +1,31 @@
<?php
namespace app\common\service;
use app\common\validate\VehVehicleUnitValidate;
use app\common\traits\crud\CrudServiceTrait;
use app\common\model\VehVehicleUnit;
class VehVehicleUnitService
{
use CrudServiceTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = VehVehicleUnit::class;
/**
* 验证器
*/
protected $validator = VehVehicleUnitValidate::class;
/**
* 获取验证器
*/
public function getValidator()
{
return new $this->validator;
}
}
+4 -4
View File
@@ -156,11 +156,11 @@ class Generate
protected function deleteFileIfExists($path)
{
if (file_exists($path)) {
"文件: <comment>{$path}</comment> 已存在,核对后手动删除。\n";
return;
// "文件: <comment>{$path}</comment> 已存在,核对后手动删除。\n";
// return;
// unlink($path);
// echo "删除已存在文件: <comment>{$path}</comment> \n";
unlink($path);
echo "删除已存在文件: <comment>{$path}</comment> \n";
}
}
@@ -0,0 +1,29 @@
<?php
namespace app\common\validate;
use think\Validate;
class VehDictI18nValidate extends Validate
{
// 规则
protected $rule = [
];
// 提示
protected $message = [
];
// 场景
protected $scene = [
'store' => [
],
'update' => [
]
];
}
@@ -0,0 +1,29 @@
<?php
namespace app\common\validate;
use think\Validate;
class VehDictMappingValidate extends Validate
{
// 规则
protected $rule = [
];
// 提示
protected $message = [
];
// 场景
protected $scene = [
'store' => [
],
'update' => [
]
];
}
@@ -0,0 +1,29 @@
<?php
namespace app\common\validate;
use think\Validate;
class VehVehicleListingValidate extends Validate
{
// 规则
protected $rule = [
];
// 提示
protected $message = [
];
// 场景
protected $scene = [
'store' => [
],
'update' => [
]
];
}
@@ -0,0 +1,128 @@
<?php
namespace app\common\validate;
use think\Validate;
class VehVehicleSkuValidate extends Validate
{
/**
* 规则定义
*/
protected $rule = [
'sku_code' => 'max:40|alphaNum',
'fingerprint' => 'length:64|alphaNum',
'brand_code' => 'require|max:32',
'brand_name' => 'require|max:64',
'series_code' => 'max:64',
'series_name' => 'max:128',
'model_code' => 'max:64',
'model_name' => 'require|max:128',
'model_year' => 'require|number|between:1900,2100',
'trim_code' => 'max:64',
'trim_level_code' => 'max:64',
'edition_code' => 'max:64',
'trim' => 'max:128',
'trim_level' => 'max:64',
'edition_name' => 'max:64',
'body_type_code' => 'max:32',
'drive_type_code' => 'max:16',
'motor_layout_code' => 'max:32',
'fuel_type_code' => 'max:16',
'engine_disp_l' => 'float|between:0,99.9',
'engine_turbo' => 'in:0,1',
'engine_power_kw' => 'number|between:0,2000',
'battery_kwh' => 'float|between:0,999.99',
'transmission_code' => 'max:16',
'gears' => 'number|between:1,20',
'range_km' => 'number|between:0,2000',
'range_std_code' => 'max:16',
'doors' => 'number|between:2,10',
'seats' => 'number|between:1,20',
'weight_kg' => 'number|between:0,10000',
'dimensions_mm' => 'array',
'color_exterior_code'=> 'max:32',
'color_exterior_text'=> 'max:64',
'color_interior_code'=> 'max:32',
'color_interior_text'=> 'max:64',
'options_official' => 'array',
'features' => 'array',
'emission_std_code' => 'max:16',
'origin_country' => 'alpha|length:2',
'currency' => 'require|alpha|length:3',
'msrp' => 'float|between:0,9999999999.99',
'is_active' => 'in:0,1',
'corp_id' => 'require|number',
'remark' => 'max:255',
'created_by' => 'number',
'updated_by' => 'number',
'created_at' => 'require|number',
'updated_at' => 'require|number',
];
/**
* 字段别名
*/
protected $field = [
'sku_code' => 'SKU编码',
'fingerprint' => '规格指纹',
'brand_code' => '品牌代码',
'brand_name' => '品牌名称',
'series_code' => '车系代码',
'series_name' => '车系名称',
'model_code' => '车型代码',
'model_name' => '车型名称',
'model_year' => '年款',
'trim_code' => '版型代码',
'trim_level_code' => '配置等级代码',
'edition_code' => '特别版代码',
'trim' => '版型/细分配置',
'trim_level' => '配置等级',
'edition_name' => '特别版名称',
'body_type_code' => '车身类型',
'drive_type_code' => '驱动方式',
'motor_layout_code' => '电机布置',
'fuel_type_code' => '能源类型',
'engine_disp_l' => '排量(L)',
'engine_turbo' => '是否涡轮',
'engine_power_kw' => '最大功率(kW)',
'battery_kwh' => '电池容量(kWh)',
'transmission_code' => '变速箱代码',
'gears' => '挡位数',
'range_km' => '续航里程',
'range_std_code' => '续航标准',
'doors' => '门数',
'seats' => '座位数',
'weight_kg' => '整备质量',
'dimensions_mm' => '车身尺寸',
'color_exterior_code'=> '外观色代码',
'color_exterior_text'=> '外观色名称',
'color_interior_code'=> '内饰色代码',
'color_interior_text'=> '内饰色名称',
'options_official' => '官方选装',
'features' => '功能配置',
'emission_std_code' => '排放标准',
'origin_country' => '原产国',
'currency' => '币种',
'msrp' => '建议零售价',
'is_active' => '是否启用',
'corp_id' => '公司ID',
'remark' => '备注',
'created_by' => '创建人',
'updated_by' => '更新人',
'created_at' => '创建时间',
'updated_at' => '更新时间',
];
/**
* 场景定义
*/
protected $scene = [
'store' => [
'brand_code', 'brand_name', 'model_year','currency'
],
'update' => [
'id', 'brand_code','brand_name', 'model_year','currency'
],
];
}
@@ -0,0 +1,29 @@
<?php
namespace app\common\validate;
use think\Validate;
class VehVehicleUnitValidate extends Validate
{
// 规则
protected $rule = [
];
// 提示
protected $message = [
];
// 场景
protected $scene = [
'store' => [
],
'update' => [
]
];
}
+6 -6
View File
@@ -78,25 +78,25 @@ class IndexController
$options = [
'controller' => [
'module' => 'common',
'name' => 'message_notifications'
'name' => 'veh_dict_i18n'
],
'model' => [
'module' => 'common',
'name' => 'message_notifications'
'name' => 'veh_dict_i18n'
],
'service' => [
'module' => 'common',
'name' => 'message_notifications'
'name' => 'veh_dict_i18n'
],
'logic' => [
'module' => 'common',
'name' => 'message_notifications'
'name' => 'veh_dict_i18n'
],
'validate' => [
'module' => 'common',
'name' => 'message_notifications'
'name' => 'veh_dict_i18n'
],
'table' => 'message_notifications'
'table' => 'veh_dict_i18n'
];
$result = (new Generate($options))->doGenerate();
+23
View File
@@ -308,3 +308,26 @@ if (!function_exists('generateMsgId')) {
return $date . $micro . $rand;
}
}
/**
* 生成sku_code
* 格式:年月日时分秒 + 微秒 + 随机数
* 例如:20250820175530987654321
*
* @return string
*/
if (!function_exists('skuCode')) {
function skuCode(): string
{
// 时间部分(精确到微秒)
$time = microtime(true);
$micro = sprintf("%06d", ($time - floor($time)) * 1e6);
$date = date('YmdHis', (int)$time);
// 随机部分(3位)
$rand = mt_rand(100, 999);
// 拼接:日期时间 + 微秒 + 随机数
return $date . $micro . $rand;
}
}
-110
View File
@@ -1,110 +0,0 @@
<?php
namespace app\queue\redis;
use app\inquiry\service\InquiryLogService;
use app\common\model\CrmCustomer;
use app\common\model\InquiryLog;
use Webman\RedisQueue\Consumer;
use app\common\model\User;
use app\common\utils\FeishuMessage;
use support\exception\BusinessException;
use support\Log;
class InquiryToCrm implements Consumer
{
// 要消费的队列名
public $queue = 'inquiry-to-crm';
// 连接名,对应 plugin/webman/redis-queue/redis.php 里的连接`
public $connection = 'default';
// 消费
public function consume($data)
{
try {
// 获取询盘信息
$info = InquiryLog::with(['channelInfo', 'countryInfo', 'enter'])->where('id', $data['id'])->where('sync_crm', 0)->first();
if ($info) {
// 获取用户信息
$recipient = 0;
$corpId = 0;
$uinfo = null;
if ($info->recipient) {
$recipient = $info->recipient;
$uinfo = User::where('id', $info->recipient)->first();
if ($uinfo) {
$corpId = $uinfo->corp_id;
}
}
// 同步客户数据
$crmCustomer = new CrmCustomer();
$crmCustomer->sn = $info->order_no;
$crmCustomer->corp_abbr = $corpId;
$crmCustomer->user_id = $recipient;
$crmCustomer->type = 1;
$crmCustomer->channel_id = $info->channel;
$crmCustomer->country_id = $info->country;
$crmCustomer->magnitude = $info->quality;
$crmCustomer->email = $info->email;
$crmCustomer->phone = $info->tel;
$crmCustomer->WhatsApp = $info->whatsapp;
$crmCustomer->contacts = $info->contacts;
$crmCustomer->desc_collect = $info->content;
$crmCustomer->enter_user_id = $info->enter_user_id;
$crmCustomer->obtain = time();
// 修改同步CRM状态
$info->sync_crm = 1;
// 保存
if (!$crmCustomer->save() || !$info->save()) {
throw new BusinessException('同步失败~');
}
// // 发送邮件消息
// if ($uinfo && $uinfo->email) {
// $options = [
// 'id' => $info->id,
// 'email' => $uinfo->email
// ];
// // Log::info('发送邮件消息:' . json_encode($options));
// \Webman\RedisQueue\Redis::send('email-message', $options);
// }
$wsdata = [
'sender_id' => 0,
'sender_type' => 'system',
'title' => '有新的客户线索,' . $info->order_no . ',请及时处理',
'content' => '有新的客户线索,' . $info->order_no . ',请及时处理',
'scene' => 1,
'recipient' => [$info->recipient]
];
// 异步推送
\Webman\RedisQueue\Redis::send('notice', $wsdata);
}
} catch (\Throwable $th) {
throw $th;
}
}
// 消费失败回调
/*
$package = [
'id' => 1357277951, // 消息ID
'time' => 1709170510, // 消息时间
'delay' => 0, // 延迟时间
'attempts' => 2, // 消费次数
'queue' => 'send-mail', // 队列名
'data' => ['to' => 'tom@gmail.com', 'content' => 'hello'], // 消息内容
'max_attempts' => 5, // 最大重试次数
'error' => '错误信息' // 错误信息
]
*/
public function onConsumeFailure(\Throwable $e, $package)
{
echo "consume failure\n";
echo $e->getMessage() . "\n";
// 无需反序列化
var_export($package);
}
}
-110
View File
@@ -1,110 +0,0 @@
<?php
namespace app\queue\redis;
use app\inquiry\service\InquiryLogService;
use app\common\model\CrmCustomer;
use app\common\model\InquiryLog;
use Webman\RedisQueue\Consumer;
use app\common\model\User;
use app\common\utils\FeishuMessage;
use support\exception\BusinessException;
use support\Log;
class InquiryToCrmUpdate implements Consumer
{
// 要消费的队列名
public $queue = 'inquiry-to-crm-update';
// 连接名,对应 plugin/webman/redis-queue/redis.php 里的连接`
public $connection = 'default';
// 消费
public function consume($data)
{
try {
// 获取询盘信息
$info = InquiryLog::with(['channelInfo', 'countryInfo', 'enter'])->where('id', $data['id'])->first();
$corpId = 0;
if ($info->recipient) {
$uinfo = User::where('id', $info->recipient)->where('status', 1)->first();
if ($uinfo) {
$corpId = $uinfo->corp_id;
}
}
// 获取客户信息
$crmCustomerInfo = CrmCustomer::where('sn', $info->order_no)->first();
if ($crmCustomerInfo) {
$udata = [
'corp_abbr' => $corpId,
'user_id' => $info->recipient,
'channel_id' => $info->channel,
'country_id' => $info->country,
'magnitude' => $info->quality,
'email' => $info->email,
'phone' => $info->tel,
'WhatsApp' => $info->whatsapp,
'contacts' => $info->contacts,
'desc_collect' => $info->content,
];
$crmCustomerInfo->update($udata);
} else {
// 同步客户数据
$crmCustomer = new CrmCustomer();
$crmCustomer->sn = $info->order_no;
$crmCustomer->corp_abbr = $corpId;
$crmCustomer->user_id = $info->recipient;
$crmCustomer->type = 1;
$crmCustomer->channel_id = $info->channel;
$crmCustomer->country_id = $info->country;
$crmCustomer->magnitude = $info->quality;
$crmCustomer->email = $info->email;
$crmCustomer->phone = $info->tel;
$crmCustomer->WhatsApp = $info->whatsapp;
$crmCustomer->contacts = $info->contacts;
$crmCustomer->desc_collect = $info->content;
$crmCustomer->enter_user_id = $info->enter_user_id;
// 修改同步CRM状态
$info->sync_crm = 1;
// 保存
if (!$crmCustomer->save() || !$info->save()) {
throw new BusinessException('同步失败~');
}
// 发送邮件消息
if ($uinfo && $uinfo->email) {
$options = [
'id' => $info->id,
'email' => $uinfo->email
];
\Webman\RedisQueue\Redis::send('email-message', $options);
}
}
} catch (\Throwable $th) {
throw $th;
}
}
// 消费失败回调
/*
$package = [
'id' => 1357277951, // 消息ID
'time' => 1709170510, // 消息时间
'delay' => 0, // 延迟时间
'attempts' => 2, // 消费次数
'queue' => 'send-mail', // 队列名
'data' => ['to' => 'tom@gmail.com', 'content' => 'hello'], // 消息内容
'max_attempts' => 5, // 最大重试次数
'error' => '错误信息' // 错误信息
]
*/
public function onConsumeFailure(\Throwable $e, $package)
{
echo "consume failure\n";
echo $e->getMessage() . "\n";
// 无需反序列化
var_export($package);
}
}
+12
View File
@@ -182,6 +182,18 @@ Route::group('/admin', function () {
Route::any('/allocation', [app\crmAdmin\controller\CustomerController::class, 'allocation']);
});
});
// 车辆
Route::group('/vehicle', function () {
// 基础信息
Route::group('/sku', function () {
Route::any('/list', [app\common\controller\VehVehicleSkuController::class, 'index']);
Route::any('/show', [app\common\controller\VehVehicleSkuController::class, 'show']);
Route::any('/store', [app\common\controller\VehVehicleSkuController::class, 'store']);
Route::any('/update', [app\common\controller\VehVehicleSkuController::class, 'update']);
Route::any('/destroy', [app\common\controller\VehVehicleSkuController::class, 'destroy']);
});
});
});