diff --git a/backend/app/Controller/Api/V1/SkuMappingController.php b/backend/app/Controller/Api/V1/SkuMappingController.php new file mode 100644 index 0000000..ce99fd4 --- /dev/null +++ b/backend/app/Controller/Api/V1/SkuMappingController.php @@ -0,0 +1,567 @@ + []]], + tags: ['SkuMappings'], + parameters: [ + new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)), + new OA\Parameter(name: 'per_page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15, maximum: 100)), + new OA\Parameter(name: 'company_id', in: 'query', required: false, description: '公司 ID 精确筛选', schema: new OA\Schema(type: 'integer')), + new OA\Parameter(name: 'platform_id', in: 'query', required: false, description: '平台 ID 精确筛选', schema: new OA\Schema(type: 'integer')), + new OA\Parameter(name: 'store_id', in: 'query', required: false, description: '店铺 ID 精确筛选', schema: new OA\Schema(type: 'integer')), + new OA\Parameter(name: 'origin_sku', in: 'query', required: false, description: '原始 SKU 模糊搜索', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'platform_outer_sku', in: 'query', required: false, description: '平台侧 SKU 模糊搜索', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'enabled', in: 'query', required: false, description: '启用状态', schema: new OA\Schema(type: 'boolean')), + ], + responses: [ + new OA\Response( + response: 200, + description: '获取成功', + content: new OA\JsonContent(properties: [ + new OA\Property(property: 'code', type: 'integer', example: 0), + new OA\Property(property: 'message', type: 'string', example: '获取成功'), + new OA\Property(property: 'data', properties: [ + new OA\Property(property: 'items', type: 'array', items: new OA\Items(ref: '#/components/schemas/SkuMapping')), + new OA\Property(property: 'total', type: 'integer', example: 100), + new OA\Property(property: 'page', type: 'integer', example: 1), + new OA\Property(property: 'per_page', type: 'integer', example: 15), + ], type: 'object'), + ]) + ), + new OA\Response(response: 401, description: '未认证', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + new OA\Response(response: 403, description: '无权限', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + ] + )] + #[RequestMapping(path: "", methods: "GET")] + public function index(): array + { + $query = SkuMapping::query()->select([ + 'id', 'company_id', 'platform_id', 'store_id', 'origin_sku', + 'origin_sku_id', 'platform_outer_sku', 'platform_product_id', + 'enabled', 'note', 'created_at', 'updated_at', + ]); + + $this->applyDataScope($query); + + // 筛选条件 + $filters = [ + 'company_id' => 'exact', + 'platform_id' => 'exact', + 'store_id' => 'exact', + ]; + + foreach ($filters as $field => $type) { + $value = $this->request->input($field); + if ($value !== null && $value !== '') { + $query->where($field, (int) $value); + } + } + + $origin_sku = $this->request->input('origin_sku'); + if ($origin_sku !== null && $origin_sku !== '') { + $query->where('origin_sku', 'ilike', "%{$origin_sku}%"); + } + + $platform_outer_sku = $this->request->input('platform_outer_sku'); + if ($platform_outer_sku !== null && $platform_outer_sku !== '') { + $query->where('platform_outer_sku', 'ilike', "%{$platform_outer_sku}%"); + } + + $enabled = $this->request->input('enabled'); + if ($enabled !== null && $enabled !== '') { + $query->where('enabled', filter_var($enabled, FILTER_VALIDATE_BOOLEAN)); + } + + $query->orderBy('created_at', 'desc'); + + $per_page = min(max((int) $this->request->input('per_page', 15), 1), 100); + $page = max((int) $this->request->input('page', 1), 1); + + $total = $query->count(); + $items = $query->offset(($page - 1) * $per_page)->limit($per_page)->get(); + + return [ + 'code' => 0, + 'message' => '获取成功', + 'data' => [ + 'items' => $items, + 'total' => $total, + 'page' => $page, + 'per_page' => $per_page, + ], + ]; + } + + /** + * 检查重复映射 + */ + #[OA\Get( + path: '/sku-mappings/check-duplicate', + summary: '检查同平台重复映射', + description: '检查指定 origin_sku 在某平台下是否已存在映射记录,帮助用户决定是否复用已有编码', + security: [['bearerAuth' => []]], + tags: ['SkuMappings'], + parameters: [ + new OA\Parameter(name: 'origin_sku_id', in: 'query', required: true, description: '内部 SKU ID', schema: new OA\Schema(type: 'integer')), + new OA\Parameter(name: 'platform_id', in: 'query', required: true, description: '平台 ID', schema: new OA\Schema(type: 'integer')), + ], + responses: [ + new OA\Response( + response: 200, + description: '检查完成', + content: new OA\JsonContent(properties: [ + new OA\Property(property: 'code', type: 'integer', example: 0), + new OA\Property(property: 'data', properties: [ + new OA\Property(property: 'has_duplicate', type: 'boolean', example: true), + new OA\Property(property: 'existing_mappings', type: 'array', items: new OA\Items(type: 'object')), + new OA\Property(property: 'message', type: 'string'), + ], type: 'object'), + ]) + ), + new OA\Response(response: 422, description: '参数校验失败', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + ] + )] + #[RequestMapping(path: "check-duplicate", methods: "GET")] + public function checkDuplicate(): ResponseInterface|array + { + $origin_sku_id = $this->request->input('origin_sku_id'); + $platform_id = $this->request->input('platform_id'); + + if (!$origin_sku_id || !$platform_id) { + return $this->response->json([ + 'code' => 422, + 'message' => '缺少必填参数: origin_sku_id, platform_id', + ])->withStatus(422); + } + + $result = $this->skuService->checkDuplicate((int) $origin_sku_id, (int) $platform_id); + + return [ + 'code' => 0, + 'message' => 'success', + 'data' => $result, + ]; + } + + /** + * 自动生成 platform_outer_sku + */ + #[OA\Post( + path: '/sku-mappings/generate-sku', + summary: '自动生成平台侧 SKU', + description: '根据指定策略自动生成 platform_outer_sku,同时返回重复检测结果', + security: [['bearerAuth' => []]], + tags: ['SkuMappings'], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['origin_sku_id', 'platform_id', 'strategy'], + properties: [ + new OA\Property(property: 'origin_sku_id', type: 'integer', example: 1), + new OA\Property(property: 'platform_id', type: 'integer', example: 1), + new OA\Property(property: 'strategy', type: 'string', enum: ['prefix', 'prefix_random', 'manual'], example: 'prefix'), + new OA\Property(property: 'prefix', type: 'string', example: 'C03'), + new OA\Property(property: 'random_length', type: 'integer', example: 4), + new OA\Property(property: 'manual_value', type: 'string', nullable: true), + ] + ) + ), + responses: [ + new OA\Response( + response: 200, + description: '生成成功', + content: new OA\JsonContent(properties: [ + new OA\Property(property: 'code', type: 'integer', example: 0), + new OA\Property(property: 'data', properties: [ + new OA\Property(property: 'generated_sku', type: 'string', example: 'C03_0032'), + new OA\Property(property: 'strategy_record', type: 'string', example: 'prefix:C03'), + new OA\Property(property: 'has_duplicate', type: 'boolean'), + new OA\Property(property: 'existing_mappings', type: 'array', items: new OA\Items(type: 'object')), + new OA\Property(property: 'message', type: 'string'), + ], type: 'object'), + ]) + ), + new OA\Response(response: 422, description: '参数校验失败', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + ] + )] + #[RequestMapping(path: "generate-sku", methods: "POST")] + public function generateSku(): ResponseInterface|array + { + $data = $this->request->all(); + + $required = ['origin_sku_id', 'platform_id', 'strategy']; + foreach ($required as $field) { + if (!isset($data[$field]) || $data[$field] === '') { + return $this->response->json([ + 'code' => 422, + 'message' => "缺少必填参数: {$field}", + ])->withStatus(422); + } + } + + $origin_sku_id = (int) $data['origin_sku_id']; + $platform_id = (int) $data['platform_id']; + $strategy = $data['strategy']; + $prefix = $data['prefix'] ?? ''; + $random_length = (int) ($data['random_length'] ?? 4); + $manual_value = $data['manual_value'] ?? null; + + // 查找 origin_sku 记录 + $sku_origin = SkuOrigin::query()->find($origin_sku_id); + if (!$sku_origin) { + return $this->response->json([ + 'code' => 422, + 'message' => '指定的内部 SKU 不存在', + ])->withStatus(422); + } + + // 生成 platform_outer_sku + $generated_sku = $this->skuService->generatePlatformOuterSku( + strategy: $strategy, + origin_sku: $sku_origin->sku, + prefix: $prefix, + random_length: $random_length, + manual_value: $manual_value, + ); + + $strategy_record = $this->skuService->buildStrategyRecord($strategy, $prefix, $random_length); + + // 检查重复 + $duplicate_check = $this->skuService->checkDuplicate($origin_sku_id, $platform_id); + + return [ + 'code' => 0, + 'message' => 'success', + 'data' => [ + 'generated_sku' => $generated_sku, + 'strategy_record' => $strategy_record, + 'has_duplicate' => $duplicate_check['has_duplicate'], + 'existing_mappings' => $duplicate_check['existing_mappings'], + 'message' => $duplicate_check['message'], + ], + ]; + } + + /** + * 映射详情 + */ + #[OA\Get( + path: '/sku-mappings/{id}', + summary: 'SKU 映射详情', + security: [['bearerAuth' => []]], + tags: ['SkuMappings'], + parameters: [ + new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')), + ], + responses: [ + new OA\Response( + response: 200, + description: '获取成功', + content: new OA\JsonContent(properties: [ + new OA\Property(property: 'code', type: 'integer', example: 0), + new OA\Property(property: 'message', type: 'string', example: '获取成功'), + new OA\Property(property: 'data', ref: '#/components/schemas/SkuMapping'), + ]) + ), + new OA\Response(response: 404, description: '数据不存在', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + ] + )] + #[RequestMapping(path: "{id}", methods: "GET")] + public function show(int $id): ResponseInterface|array + { + $query = SkuMapping::query(); + $this->applyDataScope($query); + $record = $query->where('id', $id)->first(); + + if (!$record) { + return $this->response->json([ + 'code' => 404, + 'message' => '数据不存在', + ])->withStatus(404); + } + + return [ + 'code' => 0, + 'message' => '获取成功', + 'data' => $record, + ]; + } + + /** + * 创建映射 + */ + #[OA\Post( + path: '/sku-mappings', + summary: '创建 SKU 映射', + security: [['bearerAuth' => []]], + tags: ['SkuMappings'], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['company_id', 'platform_id', 'origin_sku', 'platform_product_id'], + properties: [ + new OA\Property(property: 'company_id', type: 'integer', example: 3), + new OA\Property(property: 'platform_id', type: 'integer', example: 1), + new OA\Property(property: 'store_id', type: 'integer', nullable: true, example: 101), + new OA\Property(property: 'origin_sku', type: 'string', example: '0032'), + new OA\Property(property: 'origin_sku_id', type: 'integer', nullable: true, example: 1), + new OA\Property(property: 'platform_product_id', type: 'string', example: 'ITEM-001'), + new OA\Property(property: 'platform_outer_sku', type: 'string', nullable: true, example: 'C03_0032'), + new OA\Property(property: 'generation_strategy', type: 'string', nullable: true, example: 'prefix:C03'), + new OA\Property(property: 'warehouse_id', type: 'integer', nullable: true), + new OA\Property(property: 'enabled', type: 'boolean', example: true), + new OA\Property(property: 'note', type: 'string', nullable: true), + ] + ) + ), + responses: [ + new OA\Response( + response: 201, + description: '创建成功', + content: new OA\JsonContent(properties: [ + new OA\Property(property: 'code', type: 'integer', example: 0), + new OA\Property(property: 'message', type: 'string', example: '创建成功'), + new OA\Property(property: 'data', ref: '#/components/schemas/SkuMapping'), + ]) + ), + new OA\Response(response: 422, description: '参数校验失败', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + ] + )] + #[RequestMapping(path: "", methods: "POST")] + public function store(): ResponseInterface|array + { + $data = $this->request->all(); + + $required_fields = ['company_id', 'platform_id', 'origin_sku', 'platform_product_id']; + foreach ($required_fields as $field) { + if (!isset($data[$field]) || $data[$field] === '') { + return $this->response->json([ + 'code' => 422, + 'message' => "缺少必填字段: {$field}", + ])->withStatus(422); + } + } + + $record = SkuMapping::query()->create([ + 'company_id' => (int) $data['company_id'], + 'platform_id' => (int) $data['platform_id'], + 'store_id' => isset($data['store_id']) ? (int) $data['store_id'] : null, + 'origin_sku' => $data['origin_sku'], + 'origin_sku_id' => isset($data['origin_sku_id']) ? (int) $data['origin_sku_id'] : null, + 'platform_product_id' => $data['platform_product_id'], + 'platform_outer_sku' => $data['platform_outer_sku'] ?? null, + 'generation_strategy' => $data['generation_strategy'] ?? null, + 'warehouse_id' => isset($data['warehouse_id']) ? (int) $data['warehouse_id'] : null, + 'enabled' => $data['enabled'] ?? true, + 'note' => $data['note'] ?? null, + ]); + + $user_id = OperationLogService::getCurrentUserId(); + if ($user_id) { + OperationLogService::log( + $user_id, + 'create', + 'sku_mapping', + $record->id, + '创建 SKU 映射: ' . $data['origin_sku'] . ' → ' . ($data['platform_outer_sku'] ?? 'N/A'), + ip: $this->request->getHeaderLine('x-real-ip') ?: null, + ); + } + + return $this->response->json([ + 'code' => 0, + 'message' => '创建成功', + 'data' => $record, + ])->withStatus(201); + } + + /** + * 更新映射 + */ + #[OA\Put( + path: '/sku-mappings/{id}', + summary: '更新 SKU 映射', + security: [['bearerAuth' => []]], + tags: ['SkuMappings'], + parameters: [ + new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent(properties: [ + new OA\Property(property: 'origin_sku', type: 'string'), + new OA\Property(property: 'origin_sku_id', type: 'integer', nullable: true), + new OA\Property(property: 'platform_product_id', type: 'string'), + new OA\Property(property: 'platform_outer_sku', type: 'string', nullable: true), + new OA\Property(property: 'generation_strategy', type: 'string', nullable: true), + new OA\Property(property: 'store_id', type: 'integer', nullable: true), + new OA\Property(property: 'warehouse_id', type: 'integer', nullable: true), + new OA\Property(property: 'enabled', type: 'boolean'), + new OA\Property(property: 'note', type: 'string', nullable: true), + ]) + ), + responses: [ + new OA\Response( + response: 200, + description: '更新成功', + content: new OA\JsonContent(properties: [ + new OA\Property(property: 'code', type: 'integer', example: 0), + new OA\Property(property: 'message', type: 'string', example: '更新成功'), + new OA\Property(property: 'data', ref: '#/components/schemas/SkuMapping'), + ]) + ), + new OA\Response(response: 404, description: '数据不存在', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + ] + )] + #[RequestMapping(path: "{id}", methods: "PUT")] + public function update(int $id): ResponseInterface|array + { + $query = SkuMapping::query(); + $this->applyDataScope($query); + $record = $query->where('id', $id)->first(); + + if (!$record) { + return $this->response->json([ + 'code' => 404, + 'message' => '数据不存在', + ])->withStatus(404); + } + + $data = $this->request->all(); + $allowed_fields = [ + 'origin_sku', 'origin_sku_id', 'platform_product_id', 'platform_outer_sku', + 'generation_strategy', 'store_id', 'warehouse_id', 'enabled', 'note', + ]; + $update_data = array_intersect_key($data, array_flip($allowed_fields)); + + $record->update($update_data); + + $user_id = OperationLogService::getCurrentUserId(); + if ($user_id) { + OperationLogService::log( + $user_id, + 'update', + 'sku_mapping', + $record->id, + "更新 SKU 映射 #{$record->id}", + detail: ['updated_fields' => array_keys($update_data)], + ip: $this->request->getHeaderLine('x-real-ip') ?: null, + ); + } + + return [ + 'code' => 0, + 'message' => '更新成功', + 'data' => $record->fresh(), + ]; + } + + /** + * 删除映射 + */ + #[OA\Delete( + path: '/sku-mappings/{id}', + summary: '删除 SKU 映射', + security: [['bearerAuth' => []]], + tags: ['SkuMappings'], + parameters: [ + new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')), + ], + responses: [ + new OA\Response( + response: 200, + description: '删除成功', + content: new OA\JsonContent(properties: [ + new OA\Property(property: 'code', type: 'integer', example: 0), + new OA\Property(property: 'message', type: 'string', example: '删除成功'), + ]) + ), + new OA\Response(response: 404, description: '数据不存在', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + ] + )] + #[RequestMapping(path: "{id}", methods: "DELETE")] + public function destroy(int $id): ResponseInterface|array + { + $query = SkuMapping::query(); + $this->applyDataScope($query); + $record = $query->where('id', $id)->first(); + + if (!$record) { + return $this->response->json([ + 'code' => 404, + 'message' => '数据不存在', + ])->withStatus(404); + } + + $record->delete(); + + $user_id = OperationLogService::getCurrentUserId(); + if ($user_id) { + OperationLogService::log( + $user_id, + 'delete', + 'sku_mapping', + $id, + "删除 SKU 映射 #{$id}", + ip: $this->request->getHeaderLine('x-real-ip') ?: null, + ); + } + + return [ + 'code' => 0, + 'message' => '删除成功', + ]; + } + + /** + * DataScope 过滤 + */ + private function applyDataScope(\Hyperf\Database\Model\Builder $query): void + { + $scope_type = $this->request->getAttribute('scope_type'); + $scope_ids = $this->request->getAttribute('scope_ids', []); + + if ($scope_type === 'store') { + $query->whereIn('store_id', $scope_ids); + } elseif ($scope_type === 'platform') { + $query->whereIn('platform_id', $scope_ids); + } + } +} diff --git a/backend/app/Controller/Api/V1/SkuOriginController.php b/backend/app/Controller/Api/V1/SkuOriginController.php new file mode 100644 index 0000000..4c35180 --- /dev/null +++ b/backend/app/Controller/Api/V1/SkuOriginController.php @@ -0,0 +1,450 @@ + []]], + tags: ['SkuOrigins'], + parameters: [ + new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)), + new OA\Parameter(name: 'per_page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15, maximum: 100)), + new OA\Parameter(name: 'company_id', in: 'query', required: false, description: '公司 ID 精确筛选', schema: new OA\Schema(type: 'integer')), + new OA\Parameter(name: 'sku', in: 'query', required: false, description: 'SKU 编码模糊搜索', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'name', in: 'query', required: false, description: '产品名称模糊搜索', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'barcode', in: 'query', required: false, description: '条形码模糊搜索', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: '获取成功', + content: new OA\JsonContent(properties: [ + new OA\Property(property: 'code', type: 'integer', example: 0), + new OA\Property(property: 'message', type: 'string', example: '获取成功'), + new OA\Property(property: 'data', properties: [ + new OA\Property(property: 'items', type: 'array', items: new OA\Items(ref: '#/components/schemas/SkuOrigin')), + new OA\Property(property: 'total', type: 'integer', example: 100), + new OA\Property(property: 'page', type: 'integer', example: 1), + new OA\Property(property: 'per_page', type: 'integer', example: 15), + ], type: 'object'), + ]) + ), + new OA\Response(response: 401, description: '未认证', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + new OA\Response(response: 403, description: '无权限', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + ] + )] + #[RequestMapping(path: "", methods: "GET")] + public function index(): array + { + $query = SkuOrigin::query()->select([ + 'id', 'company_id', 'sku', 'barcode', 'name', 'label', 'hs', 'created_at', 'updated_at', + ]); + + // DataScope 过滤 + $this->applyDataScope($query); + + // 筛选条件 + $company_id = $this->request->input('company_id'); + if ($company_id !== null && $company_id !== '') { + $query->where('company_id', (int) $company_id); + } + + $sku = $this->request->input('sku'); + if ($sku !== null && $sku !== '') { + $query->where('sku', 'ilike', "%{$sku}%"); + } + + $name = $this->request->input('name'); + if ($name !== null && $name !== '') { + $query->where(function ($q) use ($name): void { + $q->where('name', 'ilike', "%{$name}%") + ->orWhere('label', 'ilike', "%{$name}%"); + }); + } + + $barcode = $this->request->input('barcode'); + if ($barcode !== null && $barcode !== '') { + $query->where('barcode', 'ilike', "%{$barcode}%"); + } + + $query->orderBy('created_at', 'desc'); + + // 分页 + $per_page = min(max((int) $this->request->input('per_page', 15), 1), 100); + $page = max((int) $this->request->input('page', 1), 1); + + $total = $query->count(); + $items = $query->offset(($page - 1) * $per_page)->limit($per_page)->get(); + + return [ + 'code' => 0, + 'message' => '获取成功', + 'data' => [ + 'items' => $items, + 'total' => $total, + 'page' => $page, + 'per_page' => $per_page, + ], + ]; + } + + /** + * SKU 详情 + */ + #[OA\Get( + path: '/sku-origins/{id}', + summary: '客户内部 SKU 详情', + security: [['bearerAuth' => []]], + tags: ['SkuOrigins'], + parameters: [ + new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')), + ], + responses: [ + new OA\Response( + response: 200, + description: '获取成功', + content: new OA\JsonContent(properties: [ + new OA\Property(property: 'code', type: 'integer', example: 0), + new OA\Property(property: 'message', type: 'string', example: '获取成功'), + new OA\Property(property: 'data', ref: '#/components/schemas/SkuOrigin'), + ]) + ), + new OA\Response(response: 404, description: '数据不存在', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + ] + )] + #[RequestMapping(path: "{id}", methods: "GET")] + public function show(int $id): ResponseInterface|array + { + $query = SkuOrigin::query(); + $this->applyDataScope($query); + $record = $query->where('id', $id)->first(); + + if (!$record) { + return $this->response->json([ + 'code' => 404, + 'message' => '数据不存在', + ])->withStatus(404); + } + + return [ + 'code' => 0, + 'message' => '获取成功', + 'data' => $record, + ]; + } + + /** + * 创建 SKU + */ + #[OA\Post( + path: '/sku-origins', + summary: '创建客户内部 SKU', + security: [['bearerAuth' => []]], + tags: ['SkuOrigins'], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['company_id', 'sku', 'barcode', 'name'], + properties: [ + new OA\Property(property: 'company_id', type: 'integer', example: 3), + new OA\Property(property: 'sku', type: 'string', example: '0032'), + new OA\Property(property: 'barcode', type: 'string', example: '6901234567890'), + new OA\Property(property: 'name', type: 'string', example: 'Running Shoes Model A'), + new OA\Property(property: 'label', type: 'string', nullable: true, example: '跑步鞋 A 款'), + new OA\Property(property: 'hs', type: 'string', nullable: true, example: '6403990090'), + new OA\Property(property: 'ledger', type: 'string', nullable: true), + new OA\Property(property: 'ext', type: 'object', nullable: true), + ] + ) + ), + responses: [ + new OA\Response( + response: 201, + description: '创建成功', + content: new OA\JsonContent(properties: [ + new OA\Property(property: 'code', type: 'integer', example: 0), + new OA\Property(property: 'message', type: 'string', example: '创建成功'), + new OA\Property(property: 'data', ref: '#/components/schemas/SkuOrigin'), + ]) + ), + new OA\Response(response: 422, description: '参数校验失败', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + ] + )] + #[RequestMapping(path: "", methods: "POST")] + public function store(): ResponseInterface|array + { + $data = $this->request->all(); + + // 参数校验 + $required_fields = ['company_id', 'sku', 'barcode', 'name']; + foreach ($required_fields as $field) { + if (!isset($data[$field]) || $data[$field] === '') { + return $this->response->json([ + 'code' => 422, + 'message' => "缺少必填字段: {$field}", + ])->withStatus(422); + } + } + + // 唯一性校验 (company_id + sku) + $exists = SkuOrigin::query() + ->where('company_id', (int) $data['company_id']) + ->where('sku', $data['sku']) + ->exists(); + + if ($exists) { + return $this->response->json([ + 'code' => 422, + 'message' => "该公司下 SKU '{$data['sku']}' 已存在", + ])->withStatus(422); + } + + $record = SkuOrigin::query()->create([ + 'company_id' => (int) $data['company_id'], + 'sku' => $data['sku'], + 'hs' => $data['hs'] ?? null, + 'barcode' => $data['barcode'], + 'name' => $data['name'], + 'label' => $data['label'] ?? null, + 'ledger' => $data['ledger'] ?? null, + 'ext' => $data['ext'] ?? null, + ]); + + // 记录操作日志 + $user_id = OperationLogService::getCurrentUserId(); + if ($user_id) { + OperationLogService::log( + $user_id, + 'create', + 'sku_origin', + $record->id, + "创建内部 SKU: {$record->sku}", + ip: $this->request->getHeaderLine('x-real-ip') ?: null, + ); + } + + return $this->response->json([ + 'code' => 0, + 'message' => '创建成功', + 'data' => $record, + ])->withStatus(201); + } + + /** + * 更新 SKU + */ + #[OA\Put( + path: '/sku-origins/{id}', + summary: '更新客户内部 SKU', + security: [['bearerAuth' => []]], + tags: ['SkuOrigins'], + parameters: [ + new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent(properties: [ + new OA\Property(property: 'sku', type: 'string'), + new OA\Property(property: 'barcode', type: 'string'), + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'label', type: 'string', nullable: true), + new OA\Property(property: 'hs', type: 'string', nullable: true), + new OA\Property(property: 'ledger', type: 'string', nullable: true), + new OA\Property(property: 'ext', type: 'object', nullable: true), + ]) + ), + responses: [ + new OA\Response( + response: 200, + description: '更新成功', + content: new OA\JsonContent(properties: [ + new OA\Property(property: 'code', type: 'integer', example: 0), + new OA\Property(property: 'message', type: 'string', example: '更新成功'), + new OA\Property(property: 'data', ref: '#/components/schemas/SkuOrigin'), + ]) + ), + new OA\Response(response: 404, description: '数据不存在', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + new OA\Response(response: 422, description: '参数校验失败', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + ] + )] + #[RequestMapping(path: "{id}", methods: "PUT")] + public function update(int $id): ResponseInterface|array + { + $query = SkuOrigin::query(); + $this->applyDataScope($query); + $record = $query->where('id', $id)->first(); + + if (!$record) { + return $this->response->json([ + 'code' => 404, + 'message' => '数据不存在', + ])->withStatus(404); + } + + $data = $this->request->all(); + $allowed_fields = ['sku', 'barcode', 'name', 'label', 'hs', 'ledger', 'ext']; + $update_data = array_intersect_key($data, array_flip($allowed_fields)); + + // 如果修改了 sku,检查唯一性 + if (isset($update_data['sku']) && $update_data['sku'] !== $record->sku) { + $exists = SkuOrigin::query() + ->where('company_id', $record->company_id) + ->where('sku', $update_data['sku']) + ->where('id', '!=', $id) + ->exists(); + + if ($exists) { + return $this->response->json([ + 'code' => 422, + 'message' => "该公司下 SKU '{$update_data['sku']}' 已存在", + ])->withStatus(422); + } + } + + $record->update($update_data); + + $user_id = OperationLogService::getCurrentUserId(); + if ($user_id) { + OperationLogService::log( + $user_id, + 'update', + 'sku_origin', + $record->id, + "更新内部 SKU: {$record->sku}", + detail: ['updated_fields' => array_keys($update_data)], + ip: $this->request->getHeaderLine('x-real-ip') ?: null, + ); + } + + return [ + 'code' => 0, + 'message' => '更新成功', + 'data' => $record->fresh(), + ]; + } + + /** + * 删除 SKU + */ + #[OA\Delete( + path: '/sku-origins/{id}', + summary: '删除客户内部 SKU', + description: '删除前检查是否有映射引用,有引用时禁止删除', + security: [['bearerAuth' => []]], + tags: ['SkuOrigins'], + parameters: [ + new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')), + ], + responses: [ + new OA\Response( + response: 200, + description: '删除成功', + content: new OA\JsonContent(properties: [ + new OA\Property(property: 'code', type: 'integer', example: 0), + new OA\Property(property: 'message', type: 'string', example: '删除成功'), + ]) + ), + new OA\Response(response: 404, description: '数据不存在', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + new OA\Response(response: 409, description: '存在映射引用', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + ] + )] + #[RequestMapping(path: "{id}", methods: "DELETE")] + public function destroy(int $id): ResponseInterface|array + { + $query = SkuOrigin::query(); + $this->applyDataScope($query); + $record = $query->where('id', $id)->first(); + + if (!$record) { + return $this->response->json([ + 'code' => 404, + 'message' => '数据不存在', + ])->withStatus(404); + } + + // 检查映射引用 + if ($this->skuService->hasReferences($id)) { + return $this->response->json([ + 'code' => 409, + 'message' => '该 SKU 存在映射引用,请先删除相关映射记录', + ])->withStatus(409); + } + + $sku_value = $record->sku; + $record->delete(); + + $user_id = OperationLogService::getCurrentUserId(); + if ($user_id) { + OperationLogService::log( + $user_id, + 'delete', + 'sku_origin', + $id, + "删除内部 SKU: {$sku_value}", + ip: $this->request->getHeaderLine('x-real-ip') ?: null, + ); + } + + return [ + 'code' => 0, + 'message' => '删除成功', + ]; + } + + /** + * DataScope 过滤(基于 company_id) + */ + private function applyDataScope(\Hyperf\Database\Model\Builder $query): void + { + $scope_type = $this->request->getAttribute('scope_type'); + $scope_ids = $this->request->getAttribute('scope_ids', []); + + if ($scope_type === 'store') { + $company_ids = \App\Model\Store::query() + ->whereIn('id', $scope_ids) + ->distinct() + ->pluck('company_id') + ->toArray(); + $query->whereIn('company_id', $company_ids); + } elseif ($scope_type === 'platform') { + $company_ids = \App\Model\Store::query() + ->whereIn('platform_id', $scope_ids) + ->distinct() + ->pluck('company_id') + ->toArray(); + $query->whereIn('company_id', $company_ids); + } + } +} diff --git a/backend/app/Model/SkuMapping.php b/backend/app/Model/SkuMapping.php new file mode 100644 index 0000000..5467b9d --- /dev/null +++ b/backend/app/Model/SkuMapping.php @@ -0,0 +1,105 @@ + 'integer', + 'company_id' => 'integer', + 'platform_id' => 'integer', + 'origin_sku_id' => 'integer', + 'store_id' => 'integer', + 'warehouse_id' => 'integer', + 'enabled' => 'boolean', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class, 'company_id'); + } + + public function platform(): BelongsTo + { + return $this->belongsTo(Platform::class, 'platform_id'); + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class, 'store_id'); + } + + public function skuOrigin(): BelongsTo + { + return $this->belongsTo(SkuOrigin::class, 'origin_sku_id'); + } +} diff --git a/backend/app/Model/SkuOrigin.php b/backend/app/Model/SkuOrigin.php new file mode 100644 index 0000000..dbe3d11 --- /dev/null +++ b/backend/app/Model/SkuOrigin.php @@ -0,0 +1,83 @@ + 'integer', + 'company_id' => 'integer', + 'ext' => 'json', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class, 'company_id'); + } + + public function mappings(): HasMany + { + return $this->hasMany(SkuMapping::class, 'origin_sku_id'); + } +} diff --git a/backend/app/Service/SkuService.php b/backend/app/Service/SkuService.php new file mode 100644 index 0000000..1b4a925 --- /dev/null +++ b/backend/app/Service/SkuService.php @@ -0,0 +1,130 @@ + $this->generatePrefixSku($prefix, $origin_sku), + 'prefix_random' => $this->generatePrefixRandomSku($prefix, $random_length), + 'manual' => $manual_value ?? '', + default => throw new \InvalidArgumentException("不支持的生成策略: {$strategy}"), + }; + } + + /** + * 前缀模式:{prefix}_{origin_sku} + */ + private function generatePrefixSku(string $prefix, string $origin_sku): string + { + if ($prefix === '') { + return $origin_sku; + } + return "{$prefix}_{$origin_sku}"; + } + + /** + * 前缀+随机模式:{prefix}_{random_string} + */ + private function generatePrefixRandomSku(string $prefix, int $random_length): string + { + $random_part = Str::upper(Str::random($random_length)); + if ($prefix === '') { + return $random_part; + } + return "{$prefix}_{$random_part}"; + } + + /** + * 构建生成策略记录字符串 + */ + public function buildStrategyRecord(string $strategy, string $prefix = '', int $random_length = 4): string + { + return match ($strategy) { + 'prefix' => "prefix:{$prefix}", + 'prefix_random' => "prefix_random:{$prefix}:{$random_length}", + 'manual' => 'manual', + default => $strategy, + }; + } + + /** + * 检查某个 origin_sku 在指定平台下是否已存在映射记录 + * + * @return array{has_duplicate: bool, existing_mappings: array, message: string} + */ + public function checkDuplicate(int $origin_sku_id, int $platform_id): array + { + $existing = SkuMapping::query() + ->where('origin_sku_id', $origin_sku_id) + ->where('platform_id', $platform_id) + ->where('enabled', true) + ->get(['id', 'store_id', 'platform_outer_sku', 'note']); + + $has_duplicate = $existing->isNotEmpty(); + + $message = $has_duplicate + ? "该 origin_sku 在当前平台下已有 {$existing->count()} 条映射记录,可直接复用已有的 platform_outer_sku" + : ''; + + return [ + 'has_duplicate' => $has_duplicate, + 'existing_mappings' => $existing->toArray(), + 'message' => $message, + ]; + } + + /** + * 检查 origin_sku 是否有映射引用(用于删除前检查) + */ + public function hasReferences(int $origin_sku_id): bool + { + return SkuMapping::query() + ->where('origin_sku_id', $origin_sku_id) + ->exists(); + } + + /** + * 检查 platform_outer_sku 在指定平台内是否唯一 + */ + public function isPlatformOuterSkuUnique(string $platform_outer_sku, int $platform_id, ?int $exclude_id = null): bool + { + $query = SkuMapping::query() + ->where('platform_outer_sku', $platform_outer_sku) + ->where('platform_id', $platform_id); + + if ($exclude_id !== null) { + $query->where('id', '!=', $exclude_id); + } + + return !$query->exists(); + } +} diff --git a/backend/migrations/2026_04_14_100000_extend_skus_mapping_table.php b/backend/migrations/2026_04_14_100000_extend_skus_mapping_table.php new file mode 100644 index 0000000..ff65501 --- /dev/null +++ b/backend/migrations/2026_04_14_100000_extend_skus_mapping_table.php @@ -0,0 +1,44 @@ +bigInteger('origin_sku_id')->nullable()->comment('关联 skus_origin.id(FK)'); + $table->text('platform_outer_sku')->nullable()->comment('平台侧使用的 SKU 编码'); + $table->text('generation_strategy')->nullable()->comment('生成策略记录,如 prefix:C03、prefix_random:C03:4、manual'); + + $table->index(['company_id', 'origin_sku_id', 'platform_id'], 'idx_company_origin_platform'); + $table->index(['platform_outer_sku', 'platform_id'], 'idx_outer_sku_platform'); + + $table->foreign('origin_sku_id') + ->references('id') + ->on('skus_origin') + ->onDelete('restrict') + ->onUpdate('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('skus_mapping', function (Blueprint $table) { + $table->dropForeign(['origin_sku_id']); + $table->dropIndex('idx_company_origin_platform'); + $table->dropIndex('idx_outer_sku_platform'); + $table->dropColumn(['origin_sku_id', 'platform_outer_sku', 'generation_strategy']); + }); + } +}; diff --git a/backend/test/Cases/Unit/Service/SkuServiceTest.php b/backend/test/Cases/Unit/Service/SkuServiceTest.php new file mode 100644 index 0000000..b794cb5 --- /dev/null +++ b/backend/test/Cases/Unit/Service/SkuServiceTest.php @@ -0,0 +1,127 @@ +service = new SkuService(); + } + + public function test_generate_prefix_sku(): void + { + $result = $this->service->generatePlatformOuterSku( + strategy: 'prefix', + origin_sku: '0032', + prefix: 'C03', + ); + + $this->assertSame('C03_0032', $result); + } + + public function test_generate_prefix_sku_without_prefix(): void + { + $result = $this->service->generatePlatformOuterSku( + strategy: 'prefix', + origin_sku: '0032', + prefix: '', + ); + + $this->assertSame('0032', $result); + } + + public function test_generate_prefix_random_sku(): void + { + $result = $this->service->generatePlatformOuterSku( + strategy: 'prefix_random', + origin_sku: '0032', + prefix: 'C03', + random_length: 4, + ); + + $this->assertStringStartsWith('C03_', $result); + // prefix(3) + _(1) + random(4) = 8 + $this->assertSame(8, strlen($result)); + } + + public function test_generate_prefix_random_sku_without_prefix(): void + { + $result = $this->service->generatePlatformOuterSku( + strategy: 'prefix_random', + origin_sku: '0032', + prefix: '', + random_length: 6, + ); + + // 纯随机部分,6 个字符 + $this->assertSame(6, strlen($result)); + } + + public function test_generate_manual_sku(): void + { + $result = $this->service->generatePlatformOuterSku( + strategy: 'manual', + origin_sku: '0032', + manual_value: 'CUSTOM_SKU_001', + ); + + $this->assertSame('CUSTOM_SKU_001', $result); + } + + public function test_generate_manual_sku_with_null_value(): void + { + $result = $this->service->generatePlatformOuterSku( + strategy: 'manual', + origin_sku: '0032', + manual_value: null, + ); + + $this->assertSame('', $result); + } + + public function test_generate_invalid_strategy_throws_exception(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('不支持的生成策略: unknown'); + + $this->service->generatePlatformOuterSku( + strategy: 'unknown', + origin_sku: '0032', + ); + } + + public function test_build_strategy_record_prefix(): void + { + $result = $this->service->buildStrategyRecord('prefix', 'C03'); + $this->assertSame('prefix:C03', $result); + } + + public function test_build_strategy_record_prefix_random(): void + { + $result = $this->service->buildStrategyRecord('prefix_random', 'C03', 4); + $this->assertSame('prefix_random:C03:4', $result); + } + + public function test_build_strategy_record_manual(): void + { + $result = $this->service->buildStrategyRecord('manual'); + $this->assertSame('manual', $result); + } +} diff --git a/frontend/src/components/ApiKeyPanel.vue b/frontend/src/components/ApiKeyPanel.vue new file mode 100644 index 0000000..7e831da --- /dev/null +++ b/frontend/src/components/ApiKeyPanel.vue @@ -0,0 +1,291 @@ + + + + + + + + + + + + + 永不过期 + + + {{ formatTime(record.expires_at) }} + + 已过期 + + + + {{ record.key_prefix }}**** + + + {{ formatTime(record.created_at) }} + + + {{ record.last_used_at ? formatTime(record.last_used_at) : '从未使用' }} + + + + + + + + + + + 生成第一个 API Key + + + + + + + + {{ apiKeyStore.keyCount }}/{{ MAX_KEYS_PER_USER }} + + + + 生成 API Key + + + + + 生成 API Key + + + + + + + {{ record.key_prefix }}**** + + + {{ formatTime(record.created_at) }} + + + 永不过期 + + + {{ formatTime(record.expires_at) }} + + 已过期 + + + + {{ record.last_used_at ? formatTime(record.last_used_at) : '从未使用' }} + + + handleToggle(record, checked)" + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ plainKey }} + + + + 复制 API Key + + + + + diff --git a/frontend/src/components/layouts/MainLayout.vue b/frontend/src/components/layouts/MainLayout.vue index b3ea66c..ee5f62d 100644 --- a/frontend/src/components/layouts/MainLayout.vue +++ b/frontend/src/components/layouts/MainLayout.vue @@ -20,6 +20,8 @@ import { FileSearchOutlined, ApiOutlined, HistoryOutlined, + BarcodeOutlined, + LinkOutlined, } from '@ant-design/icons-vue' import type { Component } from 'vue' @@ -53,6 +55,7 @@ const initOpenKeys = () => { const path = route.path if (path.startsWith('/order')) openKeys.value = ['orders-group'] else if (path.startsWith('/refund')) openKeys.value = ['refunds-group'] + else if (path.startsWith('/sku-')) openKeys.value = ['sku-group'] else if (path.startsWith('/logs')) openKeys.value = ['logs-group'] } @@ -64,6 +67,15 @@ const menuItems: MenuItem[] = [ { key: '/', icon: DashboardOutlined, label: '首页' }, { key: '/users', icon: UserOutlined, label: '用户管理', adminOnly: true }, { key: '/products', icon: ShoppingOutlined, label: '产品管理' }, + { + key: 'sku-group', + icon: BarcodeOutlined, + label: 'SKU 管理', + children: [ + { key: '/sku-origins', icon: BarcodeOutlined, label: '内部 SKU' }, + { key: '/sku-mappings', icon: LinkOutlined, label: 'SKU 映射' }, + ], + }, { key: 'orders-group', icon: FileTextOutlined, @@ -126,6 +138,8 @@ const breadcrumbItems = computed(() => { '/order-items': '订单子项', '/refunds': '退款列表', '/refund-items': '退款子项', + '/sku-origins': '内部 SKU', + '/sku-mappings': 'SKU 映射', '/roles': '角色管理', '/route-groups': '路由组管理', '/mq-status': '队列监控', diff --git a/frontend/src/pages/sku-mappings/index.vue b/frontend/src/pages/sku-mappings/index.vue new file mode 100644 index 0000000..69587cf --- /dev/null +++ b/frontend/src/pages/sku-mappings/index.vue @@ -0,0 +1,574 @@ + + + + + SKU 映射管理 + + + + + + + + + + + + + + + + 启用 + 禁用 + + + + + + + 搜索 + + + + 重置 + + + + + + + + + + 共 {{ store.pagination.total }} 条记录 + + + 新建映射 + + + + + + + {{ store.companyMap.get(record.company_id) || record.company_id }} + + + {{ store.platformMap.get(record.platform_id) || record.platform_id }} + + + + {{ store.storeMap.get(record.store_id) || record.store_id }} + + 平台默认 + + + + {{ record.enabled ? '启用' : '禁用' }} + + + + {{ formatTime(record.updated_at) }} + + + + + + 编辑 + + + + 删除 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + { + const found = store.skuOrigins.find(s => s.id === val) + form.origin_sku = found?.sku || '' + }" + /> + + + + + + + + + + + 平台 SKU + + + + + + + + + 自动生成 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 前缀模式 + 前缀+随机 + 手动输入 + + + + + + + + 前缀模式:生成 {前缀}_{内部SKU},如 C03_0032 + + + + + + + + + 前缀+随机模式:生成 {前缀}_{随机字母数字},如 C03_A7K2 + + + + + + + + + + + + + 生成预览 + + + + + + + {{ genResult }} + + + + + + + + 取消 + + 使用此 SKU + + + + + + + diff --git a/frontend/src/pages/sku-origins/index.vue b/frontend/src/pages/sku-origins/index.vue new file mode 100644 index 0000000..7ba61a4 --- /dev/null +++ b/frontend/src/pages/sku-origins/index.vue @@ -0,0 +1,296 @@ + + + + + 内部 SKU 管理 + + + + + + + + + + + + + + + + + + + + + 搜索 + + + + 重置 + + + + + + + + + + 共 {{ store.pagination.total }} 条记录 + + + 新建 SKU + + + + + + + {{ store.companyMap.get(record.company_id) || record.company_id }} + + + {{ formatTime(record.updated_at) }} + + + + + + 编辑 + + + + 删除 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/stores/api-key.ts b/frontend/src/stores/api-key.ts new file mode 100644 index 0000000..c3ed131 --- /dev/null +++ b/frontend/src/stores/api-key.ts @@ -0,0 +1,70 @@ +import { api } from '@/utils/request' +import type { ApiKeyRecord, ApiKeyCreateParams, ApiKeyCreateResult } from '@/types/api' + +export const MAX_KEYS_PER_USER = 10 + +export const useApiKeyStore = defineStore('api-key', () => { + const keys = ref([]) + const loading = ref(false) + + const canCreate = computed(() => keys.value.length < MAX_KEYS_PER_USER) + const keyCount = computed(() => keys.value.length) + + async function fetchMyKeys() { + loading.value = true + try { + const data = await api.get('/api/v1/me/api-keys') + keys.value = data + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : '获取 API Key 列表失败' + message.error(msg) + } finally { + loading.value = false + } + } + + async function createKey(params: ApiKeyCreateParams): Promise { + try { + const data = await api.post('/api/v1/me/api-keys', params) + await fetchMyKeys() + return data + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : '创建 API Key 失败' + message.error(msg) + return null + } + } + + async function toggleKey(id: number, enabled: boolean) { + try { + await api.patch(`/api/v1/me/api-keys/${id}/toggle`, { enabled }) + await fetchMyKeys() + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : '操作失败' + message.error(msg) + } + } + + async function deleteKey(id: number) { + try { + await api.delete(`/api/v1/me/api-keys/${id}`) + await fetchMyKeys() + message.success('已删除') + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : '删除失败' + message.error(msg) + } + } + + return { + keys, + loading, + canCreate, + keyCount, + MAX_KEYS_PER_USER, + fetchMyKeys, + createKey, + toggleKey, + deleteKey, + } +}) diff --git a/frontend/src/stores/sku-mapping.ts b/frontend/src/stores/sku-mapping.ts new file mode 100644 index 0000000..810ec15 --- /dev/null +++ b/frontend/src/stores/sku-mapping.ts @@ -0,0 +1,251 @@ +import { api } from '@/utils/request' +import type { PaginatedData } from '@/types/api' + +// ─── Types ─── + +export interface SkuMappingRecord { + id: number + company_id: number + platform_id: number + store_id: number | null + origin_sku: string + origin_sku_id: number | null + platform_outer_sku: string | null + platform_product_id: string + enabled: boolean + note: string | null + created_at: string + updated_at: string +} + +export interface SkuMappingFilters { + origin_sku: string + platform_outer_sku: string + enabled: boolean | undefined +} + +export interface SkuMappingForm { + company_id: number | undefined + platform_id: number | undefined + store_id: number | undefined + origin_sku: string + origin_sku_id: number | undefined + platform_product_id: string + platform_outer_sku: string + generation_strategy: string + warehouse_id: number | undefined + enabled: boolean + note: string +} + +export interface GenerateSkuParams { + origin_sku_id: number + platform_id: number + strategy: 'prefix' | 'prefix_random' | 'manual' + prefix: string + random_length: number + manual_value: string +} + +export interface GenerateSkuResult { + generated_sku: string + strategy_record: string + has_duplicate: boolean + existing_mappings: Array<{ + id: number + store_id: number | null + platform_outer_sku: string | null + note: string | null + }> + message: string +} + +export interface DuplicateCheckResult { + has_duplicate: boolean + existing_mappings: Array<{ + id: number + store_id: number | null + platform_outer_sku: string | null + note: string | null + }> + message: string +} + +// ─── Store ─── + +export const useSkuMappingStore = defineStore('skuMapping', () => { + const items = ref([]) + const loading = ref(false) + const pagination = reactive({ + page: 1, + per_page: 15, + total: 0, + }) + const cascadeValue = reactive({ + company_id: undefined as number | undefined, + platform_id: undefined as number | undefined, + store_id: undefined as number | undefined, + }) + const filters = reactive({ + origin_sku: '', + platform_outer_sku: '', + enabled: undefined, + }) + + // Lookups + const companies = ref<{ id: number; name: string; label: string | null }[]>([]) + const platforms = ref<{ id: number; name: string; label: string | null }[]>([]) + const stores = ref< + { id: number; company_id: number; platform_id: number; name: string; label: string | null }[] + >([]) + + const companyMap = computed( + () => + new Map( + companies.value.map((c) => [ + c.id, + c.label && c.label !== 'null' ? c.label : c.name, + ]), + ), + ) + const platformMap = computed( + () => + new Map( + platforms.value.map((p) => [ + p.id, + p.label && p.label !== 'null' ? p.label : p.name || `平台 #${p.id}`, + ]), + ), + ) + const storeMap = computed( + () => + new Map( + stores.value.map((s) => [ + s.id, + s.label && s.label !== 'null' ? s.label : s.name, + ]), + ), + ) + + // SKU Origin lookups for select + const skuOrigins = ref<{ id: number; company_id: number; sku: string; name: string }[]>([]) + + async function loadLookups() { + try { + const [c, p, s] = await Promise.all([ + api.get('/api/v1/companies'), + api.get('/api/v1/platforms'), + api.get('/api/v1/stores'), + ]) + companies.value = c + platforms.value = p + stores.value = s + } catch (err) { + console.warn('加载查找表数据失败', err) + } + } + + async function loadSkuOrigins(company_id?: number) { + try { + const data = await api.get>('/api/v1/sku-origins', { + company_id, + per_page: 100, + }) + skuOrigins.value = data.items + } catch (err) { + console.warn('加载 SKU Origin 列表失败', err) + } + } + + async function fetchItems() { + loading.value = true + try { + const data = await api.get>('/api/v1/sku-mappings', { + page: pagination.page, + per_page: pagination.per_page, + company_id: cascadeValue.company_id, + platform_id: cascadeValue.platform_id, + store_id: cascadeValue.store_id, + origin_sku: filters.origin_sku || undefined, + platform_outer_sku: filters.platform_outer_sku || undefined, + enabled: filters.enabled, + }) + items.value = data.items + pagination.total = data.total + pagination.page = data.page + } catch (err: unknown) { + items.value = [] + pagination.total = 0 + const msg = err instanceof Error ? err.message : '获取映射列表失败' + message.error(msg) + } finally { + loading.value = false + } + } + + function resetFilters() { + filters.origin_sku = '' + filters.platform_outer_sku = '' + filters.enabled = undefined + cascadeValue.company_id = undefined + cascadeValue.platform_id = undefined + cascadeValue.store_id = undefined + pagination.page = 1 + } + + async function createItem(form: SkuMappingForm) { + await api.post('/api/v1/sku-mappings', form) + message.success('创建成功') + await fetchItems() + } + + async function updateItem(id: number, form: Partial) { + await api.put(`/api/v1/sku-mappings/${id}`, form) + message.success('更新成功') + await fetchItems() + } + + async function deleteItem(id: number) { + await api.delete(`/api/v1/sku-mappings/${id}`) + message.success('删除成功') + await fetchItems() + } + + async function generateSku(params: GenerateSkuParams): Promise { + return await api.post('/api/v1/sku-mappings/generate-sku', params) + } + + async function checkDuplicate( + origin_sku_id: number, + platform_id: number, + ): Promise { + return await api.get('/api/v1/sku-mappings/check-duplicate', { + origin_sku_id, + platform_id, + }) + } + + return { + items, + loading, + pagination, + cascadeValue, + filters, + companies, + platforms, + stores, + companyMap, + platformMap, + storeMap, + skuOrigins, + loadLookups, + loadSkuOrigins, + fetchItems, + resetFilters, + createItem, + updateItem, + deleteItem, + generateSku, + checkDuplicate, + } +}) diff --git a/frontend/src/stores/sku-origin.ts b/frontend/src/stores/sku-origin.ts new file mode 100644 index 0000000..e9323e1 --- /dev/null +++ b/frontend/src/stores/sku-origin.ts @@ -0,0 +1,149 @@ +import { api } from '@/utils/request' +import type { PaginatedData } from '@/types/api' + +// ─── Types ─── + +export interface SkuOriginRecord { + id: number + company_id: number + sku: string + barcode: string + name: string + label: string | null + hs: string | null + created_at: string + updated_at: string +} + +export interface SkuOriginDetail extends SkuOriginRecord { + ledger: string | null + ext: Record | null +} + +export interface SkuOriginFilters { + sku: string + name: string + barcode: string +} + +export interface SkuOriginForm { + company_id: number | undefined + sku: string + barcode: string + name: string + label: string + hs: string + ledger: string +} + +// ─── Store ─── + +export const useSkuOriginStore = defineStore('skuOrigin', () => { + const items = ref([]) + const loading = ref(false) + const pagination = reactive({ + page: 1, + per_page: 15, + total: 0, + }) + const cascadeValue = reactive({ + company_id: undefined as number | undefined, + platform_id: undefined as number | undefined, + store_id: undefined as number | undefined, + }) + const filters = reactive({ + sku: '', + name: '', + barcode: '', + }) + + // Lookup maps + const companies = ref<{ id: number; name: string; label: string | null }[]>([]) + const companyMap = computed( + () => + new Map( + companies.value.map((c) => [ + c.id, + c.label && c.label !== 'null' ? c.label : c.name, + ]), + ), + ) + + async function loadLookups() { + try { + companies.value = await api.get<{ id: number; name: string; label: string | null }[]>( + '/api/v1/companies', + ) + } catch (err) { + console.warn('加载查找表数据失败', err) + } + } + + async function fetchItems() { + loading.value = true + try { + const data = await api.get>('/api/v1/sku-origins', { + page: pagination.page, + per_page: pagination.per_page, + company_id: cascadeValue.company_id, + sku: filters.sku || undefined, + name: filters.name || undefined, + barcode: filters.barcode || undefined, + }) + items.value = data.items + pagination.total = data.total + pagination.page = data.page + } catch (err: unknown) { + items.value = [] + pagination.total = 0 + const msg = err instanceof Error ? err.message : '获取 SKU 列表失败' + message.error(msg) + } finally { + loading.value = false + } + } + + function resetFilters() { + filters.sku = '' + filters.name = '' + filters.barcode = '' + cascadeValue.company_id = undefined + cascadeValue.platform_id = undefined + cascadeValue.store_id = undefined + pagination.page = 1 + } + + async function createItem(form: SkuOriginForm) { + await api.post('/api/v1/sku-origins', form) + message.success('创建成功') + await fetchItems() + } + + async function updateItem(id: number, form: Partial) { + await api.put(`/api/v1/sku-origins/${id}`, form) + message.success('更新成功') + await fetchItems() + } + + async function deleteItem(id: number) { + await api.delete(`/api/v1/sku-origins/${id}`) + message.success('删除成功') + await fetchItems() + } + + return { + items, + loading, + pagination, + cascadeValue, + filters, + companies, + companyMap, + loadLookups, + fetchItems, + resetFilters, + createItem, + updateItem, + deleteItem, + } +}) diff --git a/frontend/src/stores/user.ts b/frontend/src/stores/user.ts index 74a95e3..667402a 100644 --- a/frontend/src/stores/user.ts +++ b/frontend/src/stores/user.ts @@ -7,6 +7,7 @@ export interface UserInfo { email: string role: string status: number + api_key_enabled?: boolean ext?: Record | null } diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index b52fab4..77f59c5 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -304,6 +304,30 @@ export interface OperationLogFilters { created_at_range: [string, string] | null } +/** ─── API Key ─── */ + +export interface ApiKeyRecord { + id: number + user_id: number + name: string + key_prefix: string + last_used_at: string | null + expires_at: string | null + enabled: boolean + created_at: string + user?: { id: number; username: string; api_key_enabled?: boolean } +} + +export interface ApiKeyCreateParams { + name: string + expires_at?: string | null +} + +export interface ApiKeyCreateResult { + plain_key: string + api_key: ApiKeyRecord +} + /** 业务异常 */ export class ApiError extends Error { code: number