diff --git a/backend/app/Controller/Api/V1/SkuMappingController.php b/backend/app/Controller/Api/V1/SkuMappingController.php index 193a24a..28f36c7 100644 --- a/backend/app/Controller/Api/V1/SkuMappingController.php +++ b/backend/app/Controller/Api/V1/SkuMappingController.php @@ -50,6 +50,7 @@ class SkuMappingController extends AbstractController new OA\Parameter(name: 'platform_outer_sku', in: 'query', required: false, description: '平台侧 SKU 模糊搜索', schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'origin_sku_id', in: 'query', required: false, description: '内部 SKU ID 精确筛选', schema: new OA\Schema(type: 'integer')), new OA\Parameter(name: 'enabled', in: 'query', required: false, description: '启用状态', schema: new OA\Schema(type: 'boolean')), + new OA\Parameter(name: 'bundled', in: 'query', required: false, description: '组合商品映射筛选', schema: new OA\Schema(type: 'boolean')), ], responses: [ new OA\Response( @@ -76,7 +77,7 @@ class SkuMappingController extends AbstractController $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', + 'enabled', 'bundled', 'note', 'created_at', 'updated_at', ]); $this->applyDataScope($query); @@ -111,6 +112,11 @@ class SkuMappingController extends AbstractController $query->where('enabled', filter_var($enabled, FILTER_VALIDATE_BOOLEAN)); } + $bundled = $this->request->input('bundled'); + if ($bundled !== null && $bundled !== '') { + $query->where('bundled', filter_var($bundled, FILTER_VALIDATE_BOOLEAN)); + } + $query->orderBy('created_at', 'desc'); $per_page = min(max((int) $this->request->input('per_page', 15), 1), 100); @@ -337,18 +343,18 @@ class SkuMappingController extends AbstractController requestBody: new OA\RequestBody( required: true, content: new OA\JsonContent( - required: ['company_id', 'platform_id', 'origin_sku'], + required: ['company_id', 'platform_id', 'origin_sku_id', 'platform_outer_sku'], 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: 'origin_sku_id', type: 'integer', example: 1), + new OA\Property(property: 'platform_outer_sku', type: 'string', example: 'C03_0032'), 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: 'bundled', type: 'boolean', example: false, description: '是否为组合商品映射'), new OA\Property(property: 'note', type: 'string', nullable: true), ] ) @@ -371,7 +377,7 @@ class SkuMappingController extends AbstractController { $data = $this->request->all(); - $required_fields = ['company_id', 'platform_id', 'origin_sku']; + $required_fields = ['company_id', 'platform_id', 'origin_sku_id', 'platform_outer_sku']; foreach ($required_fields as $field) { if (!isset($data[$field]) || $data[$field] === '') { return $this->response->json([ @@ -381,17 +387,37 @@ class SkuMappingController extends AbstractController } } + $origin_sku_id = (int) $data['origin_sku_id']; + $platform_id = (int) $data['platform_id']; + $platform_outer_sku = (string) $data['platform_outer_sku']; + + $origin_sku = $this->skuService->autoFillOriginSku($origin_sku_id); + if ($origin_sku === null) { + return $this->response->json([ + 'code' => 422, + 'message' => '指定的 origin_sku_id 不存在', + ])->withStatus(422); + } + + if (!$this->skuService->isUniqueMapping($origin_sku_id, $platform_id, $platform_outer_sku)) { + return $this->response->json([ + 'code' => 422, + 'message' => '该映射组合已存在(origin_sku_id + platform_id + platform_outer_sku)', + ])->withStatus(422); + } + $record = SkuMapping::query()->create([ 'company_id' => (int) $data['company_id'], - 'platform_id' => (int) $data['platform_id'], + 'platform_id' => $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, + 'origin_sku' => $origin_sku, + 'origin_sku_id' => $origin_sku_id, 'platform_product_id' => $data['platform_product_id'] ?? null, - 'platform_outer_sku' => $data['platform_outer_sku'] ?? null, + 'platform_outer_sku' => $platform_outer_sku, 'generation_strategy' => $data['generation_strategy'] ?? null, 'warehouse_id' => isset($data['warehouse_id']) ? (int) $data['warehouse_id'] : null, 'enabled' => $data['enabled'] ?? true, + 'bundled' => (bool) ($data['bundled'] ?? false), 'note' => $data['note'] ?? null, ]); @@ -402,7 +428,7 @@ class SkuMappingController extends AbstractController 'create', 'sku_mapping', $record->id, - '创建 SKU 映射: ' . $data['origin_sku'] . ' → ' . ($data['platform_outer_sku'] ?? 'N/A'), + '创建 SKU 映射: ' . $origin_sku . ' → ' . $platform_outer_sku, ip: $this->request->getHeaderLine('x-real-ip') ?: null, ); } @@ -428,14 +454,15 @@ class SkuMappingController extends AbstractController 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: 'origin_sku_id', type: 'integer'), + new OA\Property(property: 'platform_id', type: 'integer'), + new OA\Property(property: 'platform_outer_sku', type: 'string'), 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: 'bundled', type: 'boolean', description: '是否为组合商品映射'), new OA\Property(property: 'note', type: 'string', nullable: true), ]) ), @@ -468,11 +495,41 @@ class SkuMappingController extends AbstractController $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', + 'origin_sku_id', 'platform_id', 'platform_product_id', 'platform_outer_sku', + 'generation_strategy', 'store_id', 'warehouse_id', 'enabled', 'bundled', 'note', ]; $update_data = array_intersect_key($data, array_flip($allowed_fields)); + $uniqueness_keys = ['origin_sku_id', 'platform_id', 'platform_outer_sku']; + if (array_intersect_key($update_data, array_flip($uniqueness_keys)) !== []) { + $next_origin_sku_id = (int) ($update_data['origin_sku_id'] ?? $record->origin_sku_id); + $next_platform_id = (int) ($update_data['platform_id'] ?? $record->platform_id); + $next_platform_outer_sku = (string) ($update_data['platform_outer_sku'] ?? $record->platform_outer_sku); + + if (!$this->skuService->isUniqueMapping( + $next_origin_sku_id, + $next_platform_id, + $next_platform_outer_sku, + exclude_id: $record->id, + )) { + return $this->response->json([ + 'code' => 422, + 'message' => '该映射组合已存在(origin_sku_id + platform_id + platform_outer_sku)', + ])->withStatus(422); + } + } + + if (isset($update_data['origin_sku_id'])) { + $new_origin_sku = $this->skuService->autoFillOriginSku((int) $update_data['origin_sku_id']); + if ($new_origin_sku === null) { + return $this->response->json([ + 'code' => 422, + 'message' => '指定的 origin_sku_id 不存在', + ])->withStatus(422); + } + $update_data['origin_sku'] = $new_origin_sku; + } + $record->update($update_data); $user_id = OperationLogService::getCurrentUserId(); diff --git a/backend/app/Model/SkuMapping.php b/backend/app/Model/SkuMapping.php index 5467b9d..edcc678 100644 --- a/backend/app/Model/SkuMapping.php +++ b/backend/app/Model/SkuMapping.php @@ -14,12 +14,13 @@ use OpenApi\Attributes as OA; * @property int $platform_id 平台 ID * @property string $platform_product_id 平台商品 ID * @property string $origin_sku 客户侧匹配的 SKU 编码 - * @property int|null $origin_sku_id 关联 skus_origin.id - * @property string|null $platform_outer_sku 平台侧使用的 SKU 编码 + * @property int $origin_sku_id 关联 skus_origin.id + * @property string $platform_outer_sku 平台侧使用的 SKU 编码 * @property string|null $generation_strategy 生成策略记录 * @property int|null $store_id 店铺 ID * @property int|null $warehouse_id 仓库 ID * @property boolean $enabled 是否启用 + * @property boolean $bundled 是否为组合商品映射 * @property string|null $note 备注 * @property \Carbon\Carbon $created_at * @property \Carbon\Carbon $updated_at @@ -33,12 +34,13 @@ use OpenApi\Attributes as OA; new OA\Property(property: 'platform_id', type: 'integer', example: 1), new OA\Property(property: 'platform_product_id', type: 'string', example: 'ITEM-001'), 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_outer_sku', type: 'string', nullable: true, example: 'C03_0032'), + new OA\Property(property: 'origin_sku_id', type: 'integer', example: 1), + new OA\Property(property: 'platform_outer_sku', type: 'string', example: 'C03_0032'), new OA\Property(property: 'generation_strategy', type: 'string', nullable: true, example: 'prefix:C03'), new OA\Property(property: 'store_id', type: 'integer', nullable: true, example: 101), new OA\Property(property: 'warehouse_id', type: 'integer', nullable: true, example: 1), new OA\Property(property: 'enabled', type: 'boolean', example: true), + new OA\Property(property: 'bundled', type: 'boolean', example: false, description: '是否为组合商品映射'), new OA\Property(property: 'note', type: 'string', nullable: true, example: '用于 Shopee 马来站'), new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'), @@ -65,6 +67,7 @@ class SkuMapping extends Model 'store_id', 'warehouse_id', 'enabled', + 'bundled', 'note', ]; @@ -79,6 +82,7 @@ class SkuMapping extends Model 'store_id' => 'integer', 'warehouse_id' => 'integer', 'enabled' => 'boolean', + 'bundled' => 'boolean', 'created_at' => 'datetime', 'updated_at' => 'datetime', ]; diff --git a/backend/app/Service/SkuService.php b/backend/app/Service/SkuService.php index 1b4a925..feb5fdb 100644 --- a/backend/app/Service/SkuService.php +++ b/backend/app/Service/SkuService.php @@ -102,6 +102,37 @@ class SkuService ]; } + /** + * 唯一性检查:匹配数据库新唯一索引 (origin_sku_id, platform_id, platform_outer_sku) + * + * 用于 create/update 前的预检,避免击中索引冲突返回 500 + */ + public function isUniqueMapping( + int $origin_sku_id, + int $platform_id, + string $platform_outer_sku, + ?int $exclude_id = null, + ): bool { + $query = SkuMapping::query() + ->where('origin_sku_id', $origin_sku_id) + ->where('platform_id', $platform_id) + ->where('platform_outer_sku', $platform_outer_sku); + + if ($exclude_id !== null) { + $query->where('id', '!=', $exclude_id); + } + + return !$query->exists(); + } + + /** + * 读取 skus_origin.sku,用作 skus_mapping.origin_sku 快照值 + */ + public function autoFillOriginSku(int $origin_sku_id): ?string + { + return SkuOrigin::query()->find($origin_sku_id)?->sku; + } + /** * 检查 origin_sku 是否有映射引用(用于删除前检查) */ @@ -111,20 +142,4 @@ class SkuService ->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/test/Cases/Integration/Sku/SkuMappingControllerTest.php b/backend/test/Cases/Integration/Sku/SkuMappingControllerTest.php index a4922ca..a26c800 100644 --- a/backend/test/Cases/Integration/Sku/SkuMappingControllerTest.php +++ b/backend/test/Cases/Integration/Sku/SkuMappingControllerTest.php @@ -182,7 +182,8 @@ class SkuMappingControllerTest extends TestCase { $response = $this->post('/api/v1/sku-mappings', [ 'platform_id' => 1, - 'origin_sku' => 'TEST-SKU', + 'origin_sku_id' => 1, + 'platform_outer_sku' => 'TEST-SKU', ], $this->authHeaders()); $response->assertStatus(422); @@ -192,17 +193,30 @@ class SkuMappingControllerTest extends TestCase { $response = $this->post('/api/v1/sku-mappings', [ 'company_id' => 1, - 'origin_sku' => 'TEST-SKU', + 'origin_sku_id' => 1, + 'platform_outer_sku' => 'TEST-SKU', ], $this->authHeaders()); $response->assertStatus(422); } - public function test_create_requires_origin_sku(): void + public function test_create_requires_origin_sku_id(): void { $response = $this->post('/api/v1/sku-mappings', [ 'company_id' => 1, 'platform_id' => 1, + 'platform_outer_sku' => 'TEST-SKU', + ], $this->authHeaders()); + + $response->assertStatus(422); + } + + public function test_create_requires_platform_outer_sku(): void + { + $response = $this->post('/api/v1/sku-mappings', [ + 'company_id' => 1, + 'platform_id' => 1, + 'origin_sku_id' => 1, ], $this->authHeaders()); $response->assertStatus(422);