diff --git a/app/common/controller/VehDictI18nController.php b/app/common/controller/VehDictI18nController.php
new file mode 100644
index 0000000..6a87c9f
--- /dev/null
+++ b/app/common/controller/VehDictI18nController.php
@@ -0,0 +1,33 @@
+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;
+ // }
+}
\ No newline at end of file
diff --git a/app/common/controller/VehDictMappingController.php b/app/common/controller/VehDictMappingController.php
new file mode 100644
index 0000000..d1a1dfe
--- /dev/null
+++ b/app/common/controller/VehDictMappingController.php
@@ -0,0 +1,33 @@
+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;
+ // }
+}
\ No newline at end of file
diff --git a/app/common/controller/VehVehicleListingController.php b/app/common/controller/VehVehicleListingController.php
new file mode 100644
index 0000000..59d49aa
--- /dev/null
+++ b/app/common/controller/VehVehicleListingController.php
@@ -0,0 +1,33 @@
+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;
+ // }
+}
\ No newline at end of file
diff --git a/app/common/controller/VehVehicleSkuController.php b/app/common/controller/VehVehicleSkuController.php
new file mode 100644
index 0000000..b99cacb
--- /dev/null
+++ b/app/common/controller/VehVehicleSkuController.php
@@ -0,0 +1,204 @@
+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',
+ ];
+ }
+}
\ No newline at end of file
diff --git a/app/common/controller/VehVehicleUnitController.php b/app/common/controller/VehVehicleUnitController.php
new file mode 100644
index 0000000..3d2e7e2
--- /dev/null
+++ b/app/common/controller/VehVehicleUnitController.php
@@ -0,0 +1,33 @@
+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;
+ // }
+}
\ No newline at end of file
diff --git a/app/common/logic/Fingerprint.php b/app/common/logic/Fingerprint.php
new file mode 100644
index 0000000..a066ada
--- /dev/null
+++ b/app/common/logic/Fingerprint.php
@@ -0,0 +1,262 @@
+['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;
+ }
+}
\ No newline at end of file
diff --git a/app/common/logic/ModelNaming.php b/app/common/logic/ModelNaming.php
new file mode 100644
index 0000000..b50a4cf
--- /dev/null
+++ b/app/common/logic/ModelNaming.php
@@ -0,0 +1,101 @@
+ 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';
+ }
+}
\ No newline at end of file
diff --git a/app/common/logic/SkuCodeBuilder.php b/app/common/logic/SkuCodeBuilder.php
new file mode 100644
index 0000000..823b8dc
--- /dev/null
+++ b/app/common/logic/SkuCodeBuilder.php
@@ -0,0 +1,233 @@
+ '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;
+ }
+}
\ No newline at end of file
diff --git a/app/common/logic/VehDictI18nLogic.php b/app/common/logic/VehDictI18nLogic.php
new file mode 100644
index 0000000..f25b50b
--- /dev/null
+++ b/app/common/logic/VehDictI18nLogic.php
@@ -0,0 +1,17 @@
+ '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'
+ ];
+}
\ No newline at end of file
diff --git a/app/common/model/VehVehicleListing.php b/app/common/model/VehVehicleListing.php
new file mode 100644
index 0000000..77d238c
--- /dev/null
+++ b/app/common/model/VehVehicleListing.php
@@ -0,0 +1,49 @@
+ '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'
+ ];
+}
\ No newline at end of file
diff --git a/app/common/model/VehVehicleUnit.php b/app/common/model/VehVehicleUnit.php
new file mode 100644
index 0000000..28e38f1
--- /dev/null
+++ b/app/common/model/VehVehicleUnit.php
@@ -0,0 +1,49 @@
+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}");
+ }
+}
\ No newline at end of file
diff --git a/app/common/service/VehDictMappingService.php b/app/common/service/VehDictMappingService.php
new file mode 100644
index 0000000..67c5288
--- /dev/null
+++ b/app/common/service/VehDictMappingService.php
@@ -0,0 +1,57 @@
+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]);
+ }
+}
\ No newline at end of file
diff --git a/app/common/service/VehVehicleListingService.php b/app/common/service/VehVehicleListingService.php
new file mode 100644
index 0000000..3aee146
--- /dev/null
+++ b/app/common/service/VehVehicleListingService.php
@@ -0,0 +1,31 @@
+validator;
+ }
+}
\ No newline at end of file
diff --git a/app/common/service/VehVehicleSkuService.php b/app/common/service/VehVehicleSkuService.php
new file mode 100644
index 0000000..2cfb588
--- /dev/null
+++ b/app/common/service/VehVehicleSkuService.php
@@ -0,0 +1,349 @@
+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];
+ }
+}
\ No newline at end of file
diff --git a/app/common/service/VehVehicleUnitService.php b/app/common/service/VehVehicleUnitService.php
new file mode 100644
index 0000000..4af3ea4
--- /dev/null
+++ b/app/common/service/VehVehicleUnitService.php
@@ -0,0 +1,31 @@
+validator;
+ }
+}
\ No newline at end of file
diff --git a/app/common/utils/scaffold/Generate.php b/app/common/utils/scaffold/Generate.php
index eb82025..2ed10a0 100644
--- a/app/common/utils/scaffold/Generate.php
+++ b/app/common/utils/scaffold/Generate.php
@@ -156,11 +156,11 @@ class Generate
protected function deleteFileIfExists($path)
{
if (file_exists($path)) {
- "文件: {$path} 已存在,核对后手动删除。\n";
- return;
+ // "文件: {$path} 已存在,核对后手动删除。\n";
+ // return;
- // unlink($path);
- // echo "删除已存在文件: {$path} \n";
+ unlink($path);
+ echo "删除已存在文件: {$path} \n";
}
}
diff --git a/app/common/validate/VehDictI18nValidate.php b/app/common/validate/VehDictI18nValidate.php
new file mode 100644
index 0000000..cb65500
--- /dev/null
+++ b/app/common/validate/VehDictI18nValidate.php
@@ -0,0 +1,29 @@
+ [
+
+ ],
+
+ 'update' => [
+
+ ]
+ ];
+}
\ No newline at end of file
diff --git a/app/common/validate/VehDictMappingValidate.php b/app/common/validate/VehDictMappingValidate.php
new file mode 100644
index 0000000..1b4bc7a
--- /dev/null
+++ b/app/common/validate/VehDictMappingValidate.php
@@ -0,0 +1,29 @@
+ [
+
+ ],
+
+ 'update' => [
+
+ ]
+ ];
+}
\ No newline at end of file
diff --git a/app/common/validate/VehVehicleListingValidate.php b/app/common/validate/VehVehicleListingValidate.php
new file mode 100644
index 0000000..ffa9c83
--- /dev/null
+++ b/app/common/validate/VehVehicleListingValidate.php
@@ -0,0 +1,29 @@
+ [
+
+ ],
+
+ 'update' => [
+
+ ]
+ ];
+}
\ No newline at end of file
diff --git a/app/common/validate/VehVehicleSkuValidate.php b/app/common/validate/VehVehicleSkuValidate.php
new file mode 100644
index 0000000..7305e71
--- /dev/null
+++ b/app/common/validate/VehVehicleSkuValidate.php
@@ -0,0 +1,128 @@
+ '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'
+ ],
+ ];
+}
\ No newline at end of file
diff --git a/app/common/validate/VehVehicleUnitValidate.php b/app/common/validate/VehVehicleUnitValidate.php
new file mode 100644
index 0000000..11f91a9
--- /dev/null
+++ b/app/common/validate/VehVehicleUnitValidate.php
@@ -0,0 +1,29 @@
+ [
+
+ ],
+
+ 'update' => [
+
+ ]
+ ];
+}
\ No newline at end of file
diff --git a/app/controller/IndexController.php b/app/controller/IndexController.php
index ba87d98..3871c59 100644
--- a/app/controller/IndexController.php
+++ b/app/controller/IndexController.php
@@ -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();
diff --git a/app/functions.php b/app/functions.php
index d9bb411..db8cfe7 100644
--- a/app/functions.php
+++ b/app/functions.php
@@ -304,6 +304,29 @@ if (!function_exists('generateMsgId')) {
// 随机部分(3位)
$rand = mt_rand(100, 999);
+ // 拼接:日期时间 + 微秒 + 随机数
+ 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;
}
diff --git a/app/queue/redis/InquiryToCrm.php b/app/queue/redis/InquiryToCrm.php
deleted file mode 100644
index 4d7ca3e..0000000
--- a/app/queue/redis/InquiryToCrm.php
+++ /dev/null
@@ -1,110 +0,0 @@
-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);
- }
-}
\ No newline at end of file
diff --git a/app/queue/redis/InquiryToCrmUpdate.php b/app/queue/redis/InquiryToCrmUpdate.php
deleted file mode 100644
index c4e6d02..0000000
--- a/app/queue/redis/InquiryToCrmUpdate.php
+++ /dev/null
@@ -1,110 +0,0 @@
-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);
- }
-}
\ No newline at end of file
diff --git a/config/route.php b/config/route.php
index 6393f2d..b2cb132 100644
--- a/config/route.php
+++ b/config/route.php
@@ -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']);
+ });
+ });
});