add sku mapping
This commit is contained in:
@@ -0,0 +1,567 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Api\V1;
|
||||||
|
|
||||||
|
use App\Controller\AbstractController;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
use App\Middleware\PermissionMiddleware;
|
||||||
|
use App\Model\SkuMapping;
|
||||||
|
use App\Model\SkuOrigin;
|
||||||
|
use App\Model\Store;
|
||||||
|
use App\Service\OperationLogService;
|
||||||
|
use App\Service\SkuService;
|
||||||
|
use Hyperf\HttpServer\Annotation\Controller;
|
||||||
|
use Hyperf\HttpServer\Annotation\Middleware;
|
||||||
|
use Hyperf\HttpServer\Annotation\RequestMapping;
|
||||||
|
use OpenApi\Attributes as OA;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SKU 平台映射管理接口
|
||||||
|
*/
|
||||||
|
#[OA\Tag(name: 'SkuMappings', description: 'SKU 平台映射管理')]
|
||||||
|
#[Controller(prefix: "/api/v1/sku-mappings")]
|
||||||
|
#[Middleware(AuthMiddleware::class)]
|
||||||
|
#[Middleware(PermissionMiddleware::class)]
|
||||||
|
class SkuMappingController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected readonly SkuService $skuService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 映射列表
|
||||||
|
*/
|
||||||
|
#[OA\Get(
|
||||||
|
path: '/sku-mappings',
|
||||||
|
summary: 'SKU 映射列表',
|
||||||
|
description: '获取 SKU 平台映射列表,支持分页和多维度筛选',
|
||||||
|
security: [['bearerAuth' => []]],
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Api\V1;
|
||||||
|
|
||||||
|
use App\Controller\AbstractController;
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
use App\Middleware\PermissionMiddleware;
|
||||||
|
use App\Model\SkuOrigin;
|
||||||
|
use App\Service\OperationLogService;
|
||||||
|
use App\Service\SkuService;
|
||||||
|
use Hyperf\HttpServer\Annotation\Controller;
|
||||||
|
use Hyperf\HttpServer\Annotation\Middleware;
|
||||||
|
use Hyperf\HttpServer\Annotation\RequestMapping;
|
||||||
|
use OpenApi\Attributes as OA;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户内部 SKU 管理接口
|
||||||
|
*/
|
||||||
|
#[OA\Tag(name: 'SkuOrigins', description: '客户内部 SKU 管理')]
|
||||||
|
#[Controller(prefix: "/api/v1/sku-origins")]
|
||||||
|
#[Middleware(AuthMiddleware::class)]
|
||||||
|
#[Middleware(PermissionMiddleware::class)]
|
||||||
|
class SkuOriginController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected readonly SkuService $skuService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SKU 列表
|
||||||
|
*/
|
||||||
|
#[OA\Get(
|
||||||
|
path: '/sku-origins',
|
||||||
|
summary: '客户内部 SKU 列表',
|
||||||
|
description: '获取客户内部 SKU 列表,支持分页和筛选',
|
||||||
|
security: [['bearerAuth' => []]],
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Model;
|
||||||
|
|
||||||
|
use Hyperf\Database\Model\Relations\BelongsTo;
|
||||||
|
use Hyperf\DbConnection\Model\Model;
|
||||||
|
use OpenApi\Attributes as OA;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $id 主键
|
||||||
|
* @property int $company_id 公司 ID
|
||||||
|
* @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 string|null $generation_strategy 生成策略记录
|
||||||
|
* @property int|null $store_id 店铺 ID
|
||||||
|
* @property int|null $warehouse_id 仓库 ID
|
||||||
|
* @property boolean $enabled 是否启用
|
||||||
|
* @property string|null $note 备注
|
||||||
|
* @property \Carbon\Carbon $created_at
|
||||||
|
* @property \Carbon\Carbon $updated_at
|
||||||
|
*/
|
||||||
|
#[OA\Schema(
|
||||||
|
schema: 'SkuMapping',
|
||||||
|
description: 'SKU 平台映射',
|
||||||
|
properties: [
|
||||||
|
new OA\Property(property: 'id', type: 'integer', example: 1),
|
||||||
|
new OA\Property(property: 'company_id', type: 'integer', example: 3),
|
||||||
|
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: '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: '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'),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
class SkuMapping extends Model
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The table associated with the model.
|
||||||
|
*/
|
||||||
|
protected ?string $table = 'skus_mapping';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
*/
|
||||||
|
protected array $fillable = [
|
||||||
|
'company_id',
|
||||||
|
'platform_id',
|
||||||
|
'platform_product_id',
|
||||||
|
'origin_sku',
|
||||||
|
'origin_sku_id',
|
||||||
|
'platform_outer_sku',
|
||||||
|
'generation_strategy',
|
||||||
|
'store_id',
|
||||||
|
'warehouse_id',
|
||||||
|
'enabled',
|
||||||
|
'note',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that should be cast to native types.
|
||||||
|
*/
|
||||||
|
protected array $casts = [
|
||||||
|
'id' => '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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Model;
|
||||||
|
|
||||||
|
use Hyperf\Database\Model\Relations\BelongsTo;
|
||||||
|
use Hyperf\Database\Model\Relations\HasMany;
|
||||||
|
use Hyperf\DbConnection\Model\Model;
|
||||||
|
use OpenApi\Attributes as OA;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $id 主键
|
||||||
|
* @property int $company_id 公司 ID
|
||||||
|
* @property string $sku 客户侧提供的 SKU
|
||||||
|
* @property string|null $hs 海关商品分类编码
|
||||||
|
* @property string $barcode 条形码/GTIN
|
||||||
|
* @property string $name 产品名称(英文)
|
||||||
|
* @property string|null $label 产品名称(中文)
|
||||||
|
* @property string|null $ledger 账册与批次备注
|
||||||
|
* @property array|null $ext 扩展字段
|
||||||
|
* @property \Carbon\Carbon $created_at
|
||||||
|
* @property \Carbon\Carbon $updated_at
|
||||||
|
*/
|
||||||
|
#[OA\Schema(
|
||||||
|
schema: 'SkuOrigin',
|
||||||
|
description: '客户内部 SKU',
|
||||||
|
properties: [
|
||||||
|
new OA\Property(property: 'id', type: 'integer', example: 1),
|
||||||
|
new OA\Property(property: 'company_id', type: 'integer', example: 3),
|
||||||
|
new OA\Property(property: 'sku', type: 'string', example: '0032'),
|
||||||
|
new OA\Property(property: 'hs', type: 'string', nullable: true, example: '6403990090'),
|
||||||
|
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: 'ledger', type: 'string', nullable: true, example: '批次 2026-Q1'),
|
||||||
|
new OA\Property(property: 'ext', type: 'object', nullable: true, description: '扩展字段'),
|
||||||
|
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
|
||||||
|
new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
class SkuOrigin extends Model
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The table associated with the model.
|
||||||
|
*/
|
||||||
|
protected ?string $table = 'skus_origin';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
*/
|
||||||
|
protected array $fillable = [
|
||||||
|
'company_id',
|
||||||
|
'sku',
|
||||||
|
'hs',
|
||||||
|
'barcode',
|
||||||
|
'name',
|
||||||
|
'label',
|
||||||
|
'ledger',
|
||||||
|
'ext',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that should be cast to native types.
|
||||||
|
*/
|
||||||
|
protected array $casts = [
|
||||||
|
'id' => '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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Model\SkuMapping;
|
||||||
|
use App\Model\SkuOrigin;
|
||||||
|
use Hyperf\Stringable\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SKU 业务逻辑服务
|
||||||
|
*
|
||||||
|
* 负责 platform_outer_sku 的自动生成、重复检测等业务逻辑
|
||||||
|
*/
|
||||||
|
class SkuService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 生成 platform_outer_sku
|
||||||
|
*
|
||||||
|
* @param string $strategy 生成策略:prefix | prefix_random | manual
|
||||||
|
* @param string $origin_sku 原始 SKU 编码
|
||||||
|
* @param string $prefix 前缀
|
||||||
|
* @param int $random_length 随机部分长度(仅 prefix_random 模式)
|
||||||
|
* @param string|null $manual_value 手动输入值(仅 manual 模式)
|
||||||
|
* @return string 生成的 platform_outer_sku
|
||||||
|
*/
|
||||||
|
public function generatePlatformOuterSku(
|
||||||
|
string $strategy,
|
||||||
|
string $origin_sku,
|
||||||
|
string $prefix = '',
|
||||||
|
int $random_length = 4,
|
||||||
|
?string $manual_value = null,
|
||||||
|
): string {
|
||||||
|
return match ($strategy) {
|
||||||
|
'prefix' => $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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Hyperf\Database\Schema\Schema;
|
||||||
|
use Hyperf\Database\Schema\Blueprint;
|
||||||
|
use Hyperf\Database\Migrations\Migration;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('skus_mapping', function (Blueprint $table) {
|
||||||
|
$table->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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace HyperfTest\Cases\Unit\Service;
|
||||||
|
|
||||||
|
use App\Service\SkuService;
|
||||||
|
use HyperfTest\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SkuService 单元测试
|
||||||
|
*
|
||||||
|
* 验证 SKU 自动生成策略和辅助方法
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
* @coversNothing
|
||||||
|
*/
|
||||||
|
class SkuServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
private SkuService $service;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useApiKeyStore, MAX_KEYS_PER_USER } from '@/stores/api-key'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
CopyOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
|
import type { ApiKeyRecord } from '@/types/api'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
const apiKeyStore = useApiKeyStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const apiKeyEnabled = computed(() => userStore.user?.api_key_enabled ?? false)
|
||||||
|
const hasKeys = computed(() => apiKeyStore.keys.length > 0)
|
||||||
|
|
||||||
|
// Create modal
|
||||||
|
const createModalOpen = ref(false)
|
||||||
|
const createLoading = ref(false)
|
||||||
|
const createForm = reactive({
|
||||||
|
name: '',
|
||||||
|
expires_at: null as string | null,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Plain key modal
|
||||||
|
const plainKeyModalOpen = ref(false)
|
||||||
|
const plainKey = ref('')
|
||||||
|
|
||||||
|
function isExpired(record: ApiKeyRecord): boolean {
|
||||||
|
return !!record.expires_at && dayjs(record.expires_at).isBefore(dayjs())
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(time: string | null): string {
|
||||||
|
if (!time) return '-'
|
||||||
|
return time.replace('T', ' ').substring(0, 19)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '名称', dataIndex: 'name', width: 150 },
|
||||||
|
{ title: '前缀', dataIndex: 'key_prefix', width: 120 },
|
||||||
|
{ title: '创建时间', dataIndex: 'created_at', width: 170 },
|
||||||
|
{ title: '过期时间', key: 'expires_at', width: 170 },
|
||||||
|
{ title: '最后使用', dataIndex: 'last_used_at', width: 170 },
|
||||||
|
{ title: '状态', key: 'enabled', width: 100 },
|
||||||
|
{ title: '操作', key: 'action', width: 80 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const readonlyColumns = columns.filter((c) => c.key !== 'enabled' && c.key !== 'action')
|
||||||
|
|
||||||
|
function showCreateModal() {
|
||||||
|
createForm.name = ''
|
||||||
|
createForm.expires_at = null
|
||||||
|
createModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!createForm.name.trim()) {
|
||||||
|
message.warning('请输入 API Key 名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
createLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await apiKeyStore.createKey({
|
||||||
|
name: createForm.name.trim(),
|
||||||
|
expires_at: createForm.expires_at || undefined,
|
||||||
|
})
|
||||||
|
if (result) {
|
||||||
|
createModalOpen.value = false
|
||||||
|
plainKey.value = result.plain_key
|
||||||
|
plainKeyModalOpen.value = true
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
createLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggle(record: ApiKeyRecord, checked: boolean) {
|
||||||
|
await apiKeyStore.toggleKey(record.id, checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(record: ApiKeyRecord) {
|
||||||
|
await apiKeyStore.deleteKey(record.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCopyKey() {
|
||||||
|
navigator.clipboard.writeText(plainKey.value).then(() => {
|
||||||
|
message.success('已复制')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlainKeyModalClose() {
|
||||||
|
plainKey.value = ''
|
||||||
|
plainKeyModalOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
apiKeyStore.fetchMyKeys()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Mode 3: api_key_enabled=false 且无 Key -->
|
||||||
|
<a-empty
|
||||||
|
v-if="!apiKeyEnabled && !hasKeys"
|
||||||
|
description="API Key 功能未启用,请联系管理员开启"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Mode 4: api_key_enabled=false 且有 Key — 警告 + 只读表格 -->
|
||||||
|
<div v-else-if="!apiKeyEnabled && hasKeys">
|
||||||
|
<a-alert
|
||||||
|
type="warning"
|
||||||
|
show-icon
|
||||||
|
class="mb-4"
|
||||||
|
message="API Key 功能已被管理员关闭。您的所有 API Key 当前无法用于认证。已有 Key 不会被删除,功能重新开启后将自动恢复。如需开启,请联系管理员。"
|
||||||
|
/>
|
||||||
|
<a-table
|
||||||
|
:columns="readonlyColumns"
|
||||||
|
:data-source="apiKeyStore.keys"
|
||||||
|
:loading="apiKeyStore.loading"
|
||||||
|
:pagination="false"
|
||||||
|
row-key="id"
|
||||||
|
:row-class-name="(record: ApiKeyRecord) => isExpired(record) ? 'expired-row' : ''"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'expires_at'">
|
||||||
|
<span v-if="!record.expires_at">永不过期</span>
|
||||||
|
<span v-else>
|
||||||
|
<span :class="{ 'text-red-500': isExpired(record) }">
|
||||||
|
{{ formatTime(record.expires_at) }}
|
||||||
|
</span>
|
||||||
|
<a-tag v-if="isExpired(record)" color="red" class="ml-1">已过期</a-tag>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.dataIndex === 'key_prefix'">
|
||||||
|
{{ record.key_prefix }}****
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.dataIndex === 'created_at'">
|
||||||
|
{{ formatTime(record.created_at) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.dataIndex === 'last_used_at'">
|
||||||
|
{{ record.last_used_at ? formatTime(record.last_used_at) : '从未使用' }}
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mode 2: api_key_enabled=true 且无 Key — 引导空状态 -->
|
||||||
|
<div v-else-if="apiKeyEnabled && !hasKeys && !apiKeyStore.loading">
|
||||||
|
<a-empty description="还没有 API Key">
|
||||||
|
<a-button type="primary" @click="showCreateModal">
|
||||||
|
<template #icon><PlusOutlined /></template>
|
||||||
|
生成第一个 API Key
|
||||||
|
</a-button>
|
||||||
|
</a-empty>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mode 1: api_key_enabled=true 且有 Key — 完整面板 -->
|
||||||
|
<div v-else-if="apiKeyEnabled">
|
||||||
|
<div class="flex justify-between items-center mb-3">
|
||||||
|
<a-tag>{{ apiKeyStore.keyCount }}/{{ MAX_KEYS_PER_USER }}</a-tag>
|
||||||
|
<a-tooltip
|
||||||
|
v-if="!apiKeyStore.canCreate"
|
||||||
|
title="已达上限,请删除不需要的 Key 后再生成"
|
||||||
|
>
|
||||||
|
<a-button type="primary" disabled>
|
||||||
|
<template #icon><PlusOutlined /></template>
|
||||||
|
生成 API Key
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
<a-button
|
||||||
|
v-else
|
||||||
|
type="primary"
|
||||||
|
@click="showCreateModal"
|
||||||
|
>
|
||||||
|
<template #icon><PlusOutlined /></template>
|
||||||
|
生成 API Key
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="apiKeyStore.keys"
|
||||||
|
:loading="apiKeyStore.loading"
|
||||||
|
:pagination="false"
|
||||||
|
row-key="id"
|
||||||
|
:row-class-name="(record: ApiKeyRecord) => isExpired(record) ? 'expired-row' : ''"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.dataIndex === 'key_prefix'">
|
||||||
|
{{ record.key_prefix }}****
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.dataIndex === 'created_at'">
|
||||||
|
{{ formatTime(record.created_at) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'expires_at'">
|
||||||
|
<span v-if="!record.expires_at">永不过期</span>
|
||||||
|
<span v-else>
|
||||||
|
<span :class="{ 'text-red-500': isExpired(record) }">
|
||||||
|
{{ formatTime(record.expires_at) }}
|
||||||
|
</span>
|
||||||
|
<a-tag v-if="isExpired(record)" color="red" class="ml-1">已过期</a-tag>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.dataIndex === 'last_used_at'">
|
||||||
|
{{ record.last_used_at ? formatTime(record.last_used_at) : '从未使用' }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'enabled'">
|
||||||
|
<a-switch
|
||||||
|
:checked="record.enabled"
|
||||||
|
:disabled="isExpired(record)"
|
||||||
|
size="small"
|
||||||
|
@change="(checked: boolean) => handleToggle(record, checked)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'action'">
|
||||||
|
<a-popconfirm
|
||||||
|
title="确定要删除此 API Key 吗?"
|
||||||
|
@confirm="handleDelete(record)"
|
||||||
|
>
|
||||||
|
<a-button type="link" size="small" danger>
|
||||||
|
<template #icon><DeleteOutlined /></template>
|
||||||
|
</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<a-spin v-if="apiKeyStore.loading && !hasKeys && apiKeyEnabled" class="mt-4" />
|
||||||
|
|
||||||
|
<!-- Create Modal -->
|
||||||
|
<a-modal
|
||||||
|
v-model:open="createModalOpen"
|
||||||
|
title="生成 API Key"
|
||||||
|
:confirm-loading="createLoading"
|
||||||
|
@ok="handleCreate"
|
||||||
|
>
|
||||||
|
<a-form layout="vertical">
|
||||||
|
<a-form-item label="名称" required>
|
||||||
|
<a-input
|
||||||
|
v-model:value="createForm.name"
|
||||||
|
placeholder="例如:数据同步脚本、监控系统"
|
||||||
|
:maxlength="100"
|
||||||
|
@press-enter="handleCreate"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="过期时间">
|
||||||
|
<a-date-picker
|
||||||
|
v-model:value="createForm.expires_at"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
show-time
|
||||||
|
placeholder="留空表示永不过期"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
|
||||||
|
<!-- Plain Key Modal -->
|
||||||
|
<a-modal
|
||||||
|
:open="plainKeyModalOpen"
|
||||||
|
title="API Key 已生成"
|
||||||
|
:footer="null"
|
||||||
|
:mask-closable="false"
|
||||||
|
@cancel="handlePlainKeyModalClose"
|
||||||
|
>
|
||||||
|
<a-alert
|
||||||
|
type="warning"
|
||||||
|
show-icon
|
||||||
|
class="mb-4"
|
||||||
|
message="此密钥仅显示一次,关闭后无法再次查看。请妥善保存。"
|
||||||
|
/>
|
||||||
|
<div class="bg-gray-100 p-3 rounded font-mono text-sm break-all select-all mb-3">
|
||||||
|
{{ plainKey }}
|
||||||
|
</div>
|
||||||
|
<a-button type="primary" block @click="handleCopyKey">
|
||||||
|
<template #icon><CopyOutlined /></template>
|
||||||
|
复制 API Key
|
||||||
|
</a-button>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.expired-row) {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
FileSearchOutlined,
|
FileSearchOutlined,
|
||||||
ApiOutlined,
|
ApiOutlined,
|
||||||
HistoryOutlined,
|
HistoryOutlined,
|
||||||
|
BarcodeOutlined,
|
||||||
|
LinkOutlined,
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
|
|
||||||
@@ -53,6 +55,7 @@ const initOpenKeys = () => {
|
|||||||
const path = route.path
|
const path = route.path
|
||||||
if (path.startsWith('/order')) openKeys.value = ['orders-group']
|
if (path.startsWith('/order')) openKeys.value = ['orders-group']
|
||||||
else if (path.startsWith('/refund')) openKeys.value = ['refunds-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']
|
else if (path.startsWith('/logs')) openKeys.value = ['logs-group']
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +67,15 @@ const menuItems: MenuItem[] = [
|
|||||||
{ key: '/', icon: DashboardOutlined, label: '首页' },
|
{ key: '/', icon: DashboardOutlined, label: '首页' },
|
||||||
{ key: '/users', icon: UserOutlined, label: '用户管理', adminOnly: true },
|
{ key: '/users', icon: UserOutlined, label: '用户管理', adminOnly: true },
|
||||||
{ key: '/products', icon: ShoppingOutlined, label: '产品管理' },
|
{ 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',
|
key: 'orders-group',
|
||||||
icon: FileTextOutlined,
|
icon: FileTextOutlined,
|
||||||
@@ -126,6 +138,8 @@ const breadcrumbItems = computed(() => {
|
|||||||
'/order-items': '订单子项',
|
'/order-items': '订单子项',
|
||||||
'/refunds': '退款列表',
|
'/refunds': '退款列表',
|
||||||
'/refund-items': '退款子项',
|
'/refund-items': '退款子项',
|
||||||
|
'/sku-origins': '内部 SKU',
|
||||||
|
'/sku-mappings': 'SKU 映射',
|
||||||
'/roles': '角色管理',
|
'/roles': '角色管理',
|
||||||
'/route-groups': '路由组管理',
|
'/route-groups': '路由组管理',
|
||||||
'/mq-status': '队列监控',
|
'/mq-status': '队列监控',
|
||||||
|
|||||||
@@ -0,0 +1,574 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
useSkuMappingStore,
|
||||||
|
type SkuMappingForm,
|
||||||
|
type GenerateSkuParams,
|
||||||
|
} from '@/stores/sku-mapping'
|
||||||
|
import CascadeFilter from '@/components/CascadeFilter.vue'
|
||||||
|
import {
|
||||||
|
SearchOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
|
QuestionCircleOutlined,
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
|
import type { Rule } from 'ant-design-vue/es/form'
|
||||||
|
|
||||||
|
const store = useSkuMappingStore()
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||||||
|
{ title: '公司', key: 'company', width: 120 },
|
||||||
|
{ title: '平台', key: 'platform', width: 100 },
|
||||||
|
{ title: '店铺', key: 'store', width: 140 },
|
||||||
|
{ title: '内部 SKU', dataIndex: 'origin_sku', width: 140 },
|
||||||
|
{ title: '平台 SKU', dataIndex: 'platform_outer_sku', width: 160 },
|
||||||
|
{ title: '平台商品ID', dataIndex: 'platform_product_id', width: 160, ellipsis: true },
|
||||||
|
{ title: '状态', key: 'enabled', width: 80 },
|
||||||
|
{ title: '更新时间', key: 'updated_at', width: 170 },
|
||||||
|
{ title: '操作', key: 'action', width: 200, fixed: 'right' as const },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
const modalVisible = ref(false)
|
||||||
|
const modalTitle = ref('新建映射')
|
||||||
|
const editingId = ref<number | null>(null)
|
||||||
|
const formRef = ref()
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
const defaultForm = (): SkuMappingForm => ({
|
||||||
|
company_id: undefined,
|
||||||
|
platform_id: undefined,
|
||||||
|
store_id: undefined,
|
||||||
|
origin_sku: '',
|
||||||
|
origin_sku_id: undefined,
|
||||||
|
platform_product_id: '',
|
||||||
|
platform_outer_sku: '',
|
||||||
|
generation_strategy: 'prefix',
|
||||||
|
warehouse_id: undefined,
|
||||||
|
enabled: true,
|
||||||
|
note: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = reactive<SkuMappingForm>(defaultForm())
|
||||||
|
|
||||||
|
const rules: Record<string, Rule[]> = {
|
||||||
|
company_id: [{ required: true, message: '请选择公司', trigger: 'change' }],
|
||||||
|
platform_id: [{ required: true, message: '请选择平台', trigger: 'change' }],
|
||||||
|
origin_sku: [{ required: true, message: '请输入内部 SKU', trigger: 'blur' }],
|
||||||
|
platform_outer_sku: [{ required: true, message: '请输入或生成平台 SKU', trigger: 'blur' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generation modal
|
||||||
|
const genModalVisible = ref(false)
|
||||||
|
const genLoading = ref(false)
|
||||||
|
const genForm = reactive({
|
||||||
|
strategy: 'prefix' as 'prefix' | 'prefix_random' | 'manual',
|
||||||
|
prefix: '',
|
||||||
|
random_length: 4,
|
||||||
|
manual_value: '',
|
||||||
|
})
|
||||||
|
const genResult = ref('')
|
||||||
|
|
||||||
|
// Filtered store options based on cascade
|
||||||
|
const filteredStoreOptions = computed(() => {
|
||||||
|
let filtered = store.stores
|
||||||
|
if (form.company_id) {
|
||||||
|
filtered = filtered.filter((s) => s.company_id === form.company_id)
|
||||||
|
}
|
||||||
|
if (form.platform_id) {
|
||||||
|
filtered = filtered.filter((s) => s.platform_id === form.platform_id)
|
||||||
|
}
|
||||||
|
return filtered.map((s) => ({
|
||||||
|
value: s.id,
|
||||||
|
label: s.label && s.label !== 'null' ? s.label : s.name,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Filtered SKU origins by company
|
||||||
|
const filteredSkuOrigins = computed(() => {
|
||||||
|
if (!form.company_id) return store.skuOrigins
|
||||||
|
return store.skuOrigins.filter((s) => s.company_id === form.company_id)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.loadLookups()
|
||||||
|
store.loadSkuOrigins()
|
||||||
|
store.fetchItems()
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
store.pagination.page = 1
|
||||||
|
store.fetchItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
store.resetFilters()
|
||||||
|
store.fetchItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageChange(page: number, pageSize: number) {
|
||||||
|
store.pagination.page = page
|
||||||
|
store.pagination.per_page = pageSize
|
||||||
|
store.fetchItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(time: string | null) {
|
||||||
|
if (!time) return '-'
|
||||||
|
return time.replace('T', ' ').substring(0, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
Object.assign(form, defaultForm())
|
||||||
|
editingId.value = null
|
||||||
|
modalTitle.value = '新建映射'
|
||||||
|
modalVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(record: SkuMappingForm & { id: number }) {
|
||||||
|
editingId.value = record.id
|
||||||
|
modalTitle.value = '编辑映射'
|
||||||
|
Object.assign(form, {
|
||||||
|
company_id: record.company_id,
|
||||||
|
platform_id: record.platform_id,
|
||||||
|
store_id: record.store_id || undefined,
|
||||||
|
origin_sku: record.origin_sku || '',
|
||||||
|
origin_sku_id: record.origin_sku_id || undefined,
|
||||||
|
platform_product_id: record.platform_product_id || '',
|
||||||
|
platform_outer_sku: record.platform_outer_sku || '',
|
||||||
|
generation_strategy: record.generation_strategy || 'prefix',
|
||||||
|
warehouse_id: record.warehouse_id || undefined,
|
||||||
|
enabled: record.enabled ?? true,
|
||||||
|
note: record.note || '',
|
||||||
|
})
|
||||||
|
modalVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
try {
|
||||||
|
await formRef.value.validate()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (editingId.value) {
|
||||||
|
await store.updateItem(editingId.value, { ...form })
|
||||||
|
} else {
|
||||||
|
await store.createItem({ ...form })
|
||||||
|
}
|
||||||
|
modalVisible.value = false
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : '操作失败'
|
||||||
|
message.error(msg)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(record: { id: number; origin_sku: string; platform_outer_sku: string | null }) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认删除',
|
||||||
|
content: `确定删除映射「${record.origin_sku} → ${record.platform_outer_sku || '-'}」?`,
|
||||||
|
okText: '删除',
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: '取消',
|
||||||
|
async onOk() {
|
||||||
|
try {
|
||||||
|
await store.deleteItem(record.id)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : '删除失败'
|
||||||
|
message.error(msg)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGenerate() {
|
||||||
|
if (!form.origin_sku_id) {
|
||||||
|
message.warning('请先选择内部 SKU')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.platform_id) {
|
||||||
|
message.warning('请先选择平台')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
genForm.strategy = 'prefix'
|
||||||
|
genForm.prefix = ''
|
||||||
|
genForm.random_length = 4
|
||||||
|
genForm.manual_value = ''
|
||||||
|
genResult.value = ''
|
||||||
|
genModalVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGenerate() {
|
||||||
|
if (genForm.strategy === 'manual' && !genForm.manual_value) {
|
||||||
|
message.warning('请输入手动 SKU 值')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ((genForm.strategy === 'prefix' || genForm.strategy === 'prefix_random') && !genForm.prefix) {
|
||||||
|
message.warning('请输入前缀')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
genLoading.value = true
|
||||||
|
try {
|
||||||
|
const params: GenerateSkuParams = {
|
||||||
|
origin_sku_id: form.origin_sku_id!,
|
||||||
|
platform_id: form.platform_id!,
|
||||||
|
strategy: genForm.strategy,
|
||||||
|
prefix: genForm.prefix,
|
||||||
|
random_length: genForm.random_length,
|
||||||
|
manual_value: genForm.manual_value,
|
||||||
|
}
|
||||||
|
const result = await store.generateSku(params)
|
||||||
|
genResult.value = result.generated_sku
|
||||||
|
if (result.has_duplicate) {
|
||||||
|
message.warning(result.message || '该平台已存在相同 SKU 映射')
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : '生成失败'
|
||||||
|
message.error(msg)
|
||||||
|
} finally {
|
||||||
|
genLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyGenerated() {
|
||||||
|
if (genResult.value) {
|
||||||
|
form.platform_outer_sku = genResult.value
|
||||||
|
form.generation_strategy = genForm.strategy
|
||||||
|
genModalVisible.value = false
|
||||||
|
message.success('已填入生成的平台 SKU')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch company change to reload SKU origins
|
||||||
|
watch(
|
||||||
|
() => form.company_id,
|
||||||
|
(val) => {
|
||||||
|
store.loadSkuOrigins(val)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold mb-4">SKU 映射管理</h2>
|
||||||
|
|
||||||
|
<!-- Filter area -->
|
||||||
|
<a-card class="mb-6">
|
||||||
|
<a-form layout="inline" class="filter-form" @submit.prevent="handleSearch">
|
||||||
|
<a-form-item>
|
||||||
|
<CascadeFilter v-model="store.cascadeValue" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="内部 SKU">
|
||||||
|
<a-input
|
||||||
|
v-model:value="store.filters.origin_sku"
|
||||||
|
placeholder="内部 SKU"
|
||||||
|
allow-clear
|
||||||
|
@press-enter="handleSearch"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="平台 SKU">
|
||||||
|
<a-input
|
||||||
|
v-model:value="store.filters.platform_outer_sku"
|
||||||
|
placeholder="平台侧 SKU"
|
||||||
|
allow-clear
|
||||||
|
@press-enter="handleSearch"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="状态">
|
||||||
|
<a-select
|
||||||
|
v-model:value="store.filters.enabled"
|
||||||
|
placeholder="全部"
|
||||||
|
allow-clear
|
||||||
|
style="width: 100px"
|
||||||
|
>
|
||||||
|
<a-select-option :value="true">启用</a-select-option>
|
||||||
|
<a-select-option :value="false">禁用</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item>
|
||||||
|
<a-space>
|
||||||
|
<a-button type="primary" html-type="submit">
|
||||||
|
<template #icon><SearchOutlined /></template>
|
||||||
|
搜索
|
||||||
|
</a-button>
|
||||||
|
<a-button @click="handleReset">
|
||||||
|
<template #icon><ReloadOutlined /></template>
|
||||||
|
重置
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<a-card>
|
||||||
|
<div class="mb-4 flex justify-between items-center">
|
||||||
|
<span class="text-gray-500">共 {{ store.pagination.total }} 条记录</span>
|
||||||
|
<a-button type="primary" @click="openCreate">
|
||||||
|
<template #icon><PlusOutlined /></template>
|
||||||
|
新建映射
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="store.items"
|
||||||
|
:loading="store.loading"
|
||||||
|
:pagination="false"
|
||||||
|
row-key="id"
|
||||||
|
:scroll="{ x: 1400 }"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'company'">
|
||||||
|
{{ store.companyMap.get(record.company_id) || record.company_id }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'platform'">
|
||||||
|
{{ store.platformMap.get(record.platform_id) || record.platform_id }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'store'">
|
||||||
|
<template v-if="record.store_id">
|
||||||
|
{{ store.storeMap.get(record.store_id) || record.store_id }}
|
||||||
|
</template>
|
||||||
|
<span v-else class="text-gray-400">平台默认</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'enabled'">
|
||||||
|
<a-tag :color="record.enabled ? 'green' : 'default'">
|
||||||
|
{{ record.enabled ? '启用' : '禁用' }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'updated_at'">
|
||||||
|
{{ formatTime(record.updated_at) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'action'">
|
||||||
|
<a-space>
|
||||||
|
<a-button type="link" size="small" @click="openEdit(record)">
|
||||||
|
<template #icon><EditOutlined /></template>
|
||||||
|
编辑
|
||||||
|
</a-button>
|
||||||
|
<a-button type="link" danger size="small" @click="handleDelete(record)">
|
||||||
|
<template #icon><DeleteOutlined /></template>
|
||||||
|
删除
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
|
||||||
|
<div class="mt-4 flex justify-end">
|
||||||
|
<a-pagination
|
||||||
|
:current="store.pagination.page"
|
||||||
|
:page-size="store.pagination.per_page"
|
||||||
|
:total="store.pagination.total"
|
||||||
|
show-size-changer
|
||||||
|
show-quick-jumper
|
||||||
|
:show-total="(total: number) => `共 ${total} 条`"
|
||||||
|
@change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<!-- Create/Edit Modal -->
|
||||||
|
<a-modal
|
||||||
|
v-model:open="modalVisible"
|
||||||
|
:title="modalTitle"
|
||||||
|
:confirm-loading="saving"
|
||||||
|
@ok="handleSubmit"
|
||||||
|
@cancel="modalVisible = false"
|
||||||
|
:width="640"
|
||||||
|
>
|
||||||
|
<a-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="form"
|
||||||
|
:rules="rules"
|
||||||
|
layout="vertical"
|
||||||
|
class="mt-4"
|
||||||
|
>
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="公司" name="company_id">
|
||||||
|
<a-select
|
||||||
|
v-model:value="form.company_id"
|
||||||
|
placeholder="请选择公司"
|
||||||
|
:options="store.companies.map(c => ({
|
||||||
|
value: c.id,
|
||||||
|
label: (c.label && c.label !== 'null') ? c.label : c.name
|
||||||
|
}))"
|
||||||
|
show-search
|
||||||
|
:filter-option="(input: string, option: { label: string }) =>
|
||||||
|
option.label.toLowerCase().includes(input.toLowerCase())"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="平台" name="platform_id">
|
||||||
|
<a-select
|
||||||
|
v-model:value="form.platform_id"
|
||||||
|
placeholder="请选择平台"
|
||||||
|
:options="store.platforms.map(p => ({
|
||||||
|
value: p.id,
|
||||||
|
label: (p.label && p.label !== 'null') ? p.label : (p.name || `平台 #${p.id}`)
|
||||||
|
}))"
|
||||||
|
show-search
|
||||||
|
:filter-option="(input: string, option: { label: string }) =>
|
||||||
|
option.label.toLowerCase().includes(input.toLowerCase())"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="店铺" name="store_id">
|
||||||
|
<a-select
|
||||||
|
v-model:value="form.store_id"
|
||||||
|
placeholder="留空表示平台默认"
|
||||||
|
:options="filteredStoreOptions"
|
||||||
|
allow-clear
|
||||||
|
show-search
|
||||||
|
:filter-option="(input: string, option: { label: string }) =>
|
||||||
|
option.label.toLowerCase().includes(input.toLowerCase())"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="内部 SKU" name="origin_sku">
|
||||||
|
<a-select
|
||||||
|
v-model:value="form.origin_sku_id"
|
||||||
|
placeholder="选择已有 SKU"
|
||||||
|
:options="filteredSkuOrigins.map(s => ({
|
||||||
|
value: s.id,
|
||||||
|
label: `${s.sku} - ${s.name}`
|
||||||
|
}))"
|
||||||
|
allow-clear
|
||||||
|
show-search
|
||||||
|
:filter-option="(input: string, option: { label: string }) =>
|
||||||
|
option.label.toLowerCase().includes(input.toLowerCase())"
|
||||||
|
@change="(val: number | undefined) => {
|
||||||
|
const found = store.skuOrigins.find(s => s.id === val)
|
||||||
|
form.origin_sku = found?.sku || ''
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<a-input
|
||||||
|
v-if="!form.origin_sku_id"
|
||||||
|
v-model:value="form.origin_sku"
|
||||||
|
placeholder="或手动输入内部 SKU"
|
||||||
|
class="mt-2"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="16">
|
||||||
|
<a-form-item name="platform_outer_sku">
|
||||||
|
<template #label>
|
||||||
|
<span>
|
||||||
|
平台 SKU
|
||||||
|
<a-tooltip title="电商平台侧使用的 SKU 编码,需符合平台填写规则(如不能以 0 开头、不能含特殊字符等)">
|
||||||
|
<QuestionCircleOutlined class="ml-1 text-gray-400" />
|
||||||
|
</a-tooltip>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<a-input v-model:value="form.platform_outer_sku" placeholder="平台侧 SKU 编码">
|
||||||
|
<template #addonAfter>
|
||||||
|
<a-button type="link" size="small" @click="openGenerate" :style="{ padding: 0 }">
|
||||||
|
<ThunderboltOutlined /> 自动生成
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
</a-input>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="8">
|
||||||
|
<a-form-item label="状态">
|
||||||
|
<a-switch v-model:checked="form.enabled" checked-children="启用" un-checked-children="禁用" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-form-item label="平台商品 ID" name="platform_product_id">
|
||||||
|
<a-input v-model:value="form.platform_product_id" placeholder="可选" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="备注" name="note">
|
||||||
|
<a-textarea v-model:value="form.note" placeholder="可选备注" :rows="2" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
|
||||||
|
<!-- SKU Generation Modal -->
|
||||||
|
<a-modal
|
||||||
|
v-model:open="genModalVisible"
|
||||||
|
title="自动生成平台 SKU"
|
||||||
|
:width="480"
|
||||||
|
@cancel="genModalVisible = false"
|
||||||
|
>
|
||||||
|
<div class="mt-4">
|
||||||
|
<a-form layout="vertical">
|
||||||
|
<a-form-item label="生成策略">
|
||||||
|
<a-radio-group v-model:value="genForm.strategy">
|
||||||
|
<a-radio-button value="prefix">前缀模式</a-radio-button>
|
||||||
|
<a-radio-button value="prefix_random">前缀+随机</a-radio-button>
|
||||||
|
<a-radio-button value="manual">手动输入</a-radio-button>
|
||||||
|
</a-radio-group>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<template v-if="genForm.strategy === 'prefix' || genForm.strategy === 'prefix_random'">
|
||||||
|
<a-form-item label="前缀">
|
||||||
|
<a-input v-model:value="genForm.prefix" placeholder="如 C03" />
|
||||||
|
<div class="text-xs text-gray-400 mt-1">
|
||||||
|
前缀模式:生成 {前缀}_{内部SKU},如 C03_0032
|
||||||
|
</div>
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="genForm.strategy === 'prefix_random'">
|
||||||
|
<a-form-item label="随机部分长度">
|
||||||
|
<a-input-number v-model:value="genForm.random_length" :min="2" :max="8" />
|
||||||
|
<div class="text-xs text-gray-400 mt-1">
|
||||||
|
前缀+随机模式:生成 {前缀}_{随机字母数字},如 C03_A7K2
|
||||||
|
</div>
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="genForm.strategy === 'manual'">
|
||||||
|
<a-form-item label="手动输入">
|
||||||
|
<a-input v-model:value="genForm.manual_value" placeholder="手动输入 SKU 值" />
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<a-form-item>
|
||||||
|
<a-button type="primary" :loading="genLoading" @click="handleGenerate">
|
||||||
|
<ThunderboltOutlined /> 生成预览
|
||||||
|
</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="genResult" label="生成结果">
|
||||||
|
<a-alert type="success" show-icon>
|
||||||
|
<template #message>
|
||||||
|
<span class="font-mono text-base">{{ genResult }}</span>
|
||||||
|
</template>
|
||||||
|
</a-alert>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<a-button @click="genModalVisible = false">取消</a-button>
|
||||||
|
<a-button type="primary" :disabled="!genResult" @click="applyGenerated">
|
||||||
|
使用此 SKU
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
</a-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-form :deep(.ant-form-item) {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useSkuOriginStore, type SkuOriginForm } from '@/stores/sku-origin'
|
||||||
|
import CascadeFilter from '@/components/CascadeFilter.vue'
|
||||||
|
import {
|
||||||
|
SearchOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
|
import type { Rule } from 'ant-design-vue/es/form'
|
||||||
|
|
||||||
|
const store = useSkuOriginStore()
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||||
|
{ title: '公司', key: 'company', width: 140 },
|
||||||
|
{ title: 'SKU', dataIndex: 'sku', width: 160 },
|
||||||
|
{ title: '名称', dataIndex: 'name', ellipsis: true },
|
||||||
|
{ title: '条形码', dataIndex: 'barcode', width: 160 },
|
||||||
|
{ title: '标签', dataIndex: 'label', width: 120 },
|
||||||
|
{ title: 'HS 编码', dataIndex: 'hs', width: 120 },
|
||||||
|
{ title: '更新时间', key: 'updated_at', width: 170 },
|
||||||
|
{ title: '操作', key: 'action', width: 160, fixed: 'right' as const },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
const modalVisible = ref(false)
|
||||||
|
const modalTitle = ref('新建内部 SKU')
|
||||||
|
const editingId = ref<number | null>(null)
|
||||||
|
const formRef = ref()
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
const defaultForm = (): SkuOriginForm => ({
|
||||||
|
company_id: undefined,
|
||||||
|
sku: '',
|
||||||
|
barcode: '',
|
||||||
|
name: '',
|
||||||
|
label: '',
|
||||||
|
hs: '',
|
||||||
|
ledger: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = reactive<SkuOriginForm>(defaultForm())
|
||||||
|
|
||||||
|
const rules: Record<string, Rule[]> = {
|
||||||
|
company_id: [{ required: true, message: '请选择公司', trigger: 'change' }],
|
||||||
|
sku: [{ required: true, message: '请输入 SKU 编码', trigger: 'blur' }],
|
||||||
|
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.loadLookups()
|
||||||
|
store.fetchItems()
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
store.pagination.page = 1
|
||||||
|
store.fetchItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
store.resetFilters()
|
||||||
|
store.fetchItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageChange(page: number, pageSize: number) {
|
||||||
|
store.pagination.page = page
|
||||||
|
store.pagination.per_page = pageSize
|
||||||
|
store.fetchItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(time: string | null) {
|
||||||
|
if (!time) return '-'
|
||||||
|
return time.replace('T', ' ').substring(0, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
Object.assign(form, defaultForm())
|
||||||
|
editingId.value = null
|
||||||
|
modalTitle.value = '新建内部 SKU'
|
||||||
|
modalVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(record: { id: number; company_id: number; sku: string; barcode: string; name: string; label: string | null; hs: string | null }) {
|
||||||
|
editingId.value = record.id
|
||||||
|
modalTitle.value = '编辑内部 SKU'
|
||||||
|
Object.assign(form, {
|
||||||
|
company_id: record.company_id,
|
||||||
|
sku: record.sku,
|
||||||
|
barcode: record.barcode || '',
|
||||||
|
name: record.name || '',
|
||||||
|
label: record.label || '',
|
||||||
|
hs: record.hs || '',
|
||||||
|
ledger: '',
|
||||||
|
})
|
||||||
|
modalVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
try {
|
||||||
|
await formRef.value.validate()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (editingId.value) {
|
||||||
|
await store.updateItem(editingId.value, { ...form })
|
||||||
|
} else {
|
||||||
|
await store.createItem({ ...form })
|
||||||
|
}
|
||||||
|
modalVisible.value = false
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : '操作失败'
|
||||||
|
message.error(msg)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(record: { id: number; sku: string }) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认删除',
|
||||||
|
content: `确定删除 SKU「${record.sku}」?如果该 SKU 已被映射引用将无法删除。`,
|
||||||
|
okText: '删除',
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: '取消',
|
||||||
|
async onOk() {
|
||||||
|
try {
|
||||||
|
await store.deleteItem(record.id)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : '删除失败'
|
||||||
|
message.error(msg)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold mb-4">内部 SKU 管理</h2>
|
||||||
|
|
||||||
|
<!-- Filter area -->
|
||||||
|
<a-card class="mb-6">
|
||||||
|
<a-form layout="inline" class="filter-form" @submit.prevent="handleSearch">
|
||||||
|
<a-form-item>
|
||||||
|
<CascadeFilter v-model="store.cascadeValue" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="SKU">
|
||||||
|
<a-input
|
||||||
|
v-model:value="store.filters.sku"
|
||||||
|
placeholder="SKU 编码"
|
||||||
|
allow-clear
|
||||||
|
@press-enter="handleSearch"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="名称">
|
||||||
|
<a-input
|
||||||
|
v-model:value="store.filters.name"
|
||||||
|
placeholder="模糊搜索"
|
||||||
|
allow-clear
|
||||||
|
@press-enter="handleSearch"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="条形码">
|
||||||
|
<a-input
|
||||||
|
v-model:value="store.filters.barcode"
|
||||||
|
placeholder="条形码"
|
||||||
|
allow-clear
|
||||||
|
@press-enter="handleSearch"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item>
|
||||||
|
<a-space>
|
||||||
|
<a-button type="primary" html-type="submit">
|
||||||
|
<template #icon><SearchOutlined /></template>
|
||||||
|
搜索
|
||||||
|
</a-button>
|
||||||
|
<a-button @click="handleReset">
|
||||||
|
<template #icon><ReloadOutlined /></template>
|
||||||
|
重置
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<a-card>
|
||||||
|
<div class="mb-4 flex justify-between items-center">
|
||||||
|
<span class="text-gray-500">共 {{ store.pagination.total }} 条记录</span>
|
||||||
|
<a-button type="primary" @click="openCreate">
|
||||||
|
<template #icon><PlusOutlined /></template>
|
||||||
|
新建 SKU
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="store.items"
|
||||||
|
:loading="store.loading"
|
||||||
|
:pagination="false"
|
||||||
|
row-key="id"
|
||||||
|
:scroll="{ x: 1200 }"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'company'">
|
||||||
|
{{ store.companyMap.get(record.company_id) || record.company_id }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'updated_at'">
|
||||||
|
{{ formatTime(record.updated_at) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'action'">
|
||||||
|
<a-space>
|
||||||
|
<a-button type="link" size="small" @click="openEdit(record)">
|
||||||
|
<template #icon><EditOutlined /></template>
|
||||||
|
编辑
|
||||||
|
</a-button>
|
||||||
|
<a-button type="link" danger size="small" @click="handleDelete(record)">
|
||||||
|
<template #icon><DeleteOutlined /></template>
|
||||||
|
删除
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
|
||||||
|
<div class="mt-4 flex justify-end">
|
||||||
|
<a-pagination
|
||||||
|
:current="store.pagination.page"
|
||||||
|
:page-size="store.pagination.per_page"
|
||||||
|
:total="store.pagination.total"
|
||||||
|
show-size-changer
|
||||||
|
show-quick-jumper
|
||||||
|
:show-total="(total: number) => `共 ${total} 条`"
|
||||||
|
@change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<!-- Create/Edit Modal -->
|
||||||
|
<a-modal
|
||||||
|
v-model:open="modalVisible"
|
||||||
|
:title="modalTitle"
|
||||||
|
:confirm-loading="saving"
|
||||||
|
@ok="handleSubmit"
|
||||||
|
@cancel="modalVisible = false"
|
||||||
|
:width="560"
|
||||||
|
>
|
||||||
|
<a-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="form"
|
||||||
|
:rules="rules"
|
||||||
|
layout="vertical"
|
||||||
|
class="mt-4"
|
||||||
|
>
|
||||||
|
<a-form-item label="公司" name="company_id">
|
||||||
|
<a-select
|
||||||
|
v-model:value="form.company_id"
|
||||||
|
placeholder="请选择公司"
|
||||||
|
:options="store.companies.map(c => ({
|
||||||
|
value: c.id,
|
||||||
|
label: (c.label && c.label !== 'null') ? c.label : c.name
|
||||||
|
}))"
|
||||||
|
show-search
|
||||||
|
:filter-option="(input: string, option: { label: string }) =>
|
||||||
|
option.label.toLowerCase().includes(input.toLowerCase())"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="SKU 编码" name="sku">
|
||||||
|
<a-input v-model:value="form.sku" placeholder="客户内部 SKU 编码" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="名称" name="name">
|
||||||
|
<a-input v-model:value="form.name" placeholder="SKU 名称/描述" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="条形码" name="barcode">
|
||||||
|
<a-input v-model:value="form.barcode" placeholder="可选" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="标签" name="label">
|
||||||
|
<a-input v-model:value="form.label" placeholder="可选别名" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="HS 编码" name="hs">
|
||||||
|
<a-input v-model:value="form.hs" placeholder="可选" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-form :deep(.ant-form-item) {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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<ApiKeyRecord[]>([])
|
||||||
|
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<ApiKeyRecord[]>('/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<ApiKeyCreateResult | null> {
|
||||||
|
try {
|
||||||
|
const data = await api.post<ApiKeyCreateResult>('/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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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<SkuMappingRecord[]>([])
|
||||||
|
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<SkuMappingFilters>({
|
||||||
|
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<typeof companies.value>('/api/v1/companies'),
|
||||||
|
api.get<typeof platforms.value>('/api/v1/platforms'),
|
||||||
|
api.get<typeof stores.value>('/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<PaginatedData<{ id: number; company_id: number; sku: string; name: string }>>('/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<PaginatedData<SkuMappingRecord>>('/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<SkuMappingForm>) {
|
||||||
|
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<GenerateSkuResult> {
|
||||||
|
return await api.post<GenerateSkuResult>('/api/v1/sku-mappings/generate-sku', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkDuplicate(
|
||||||
|
origin_sku_id: number,
|
||||||
|
platform_id: number,
|
||||||
|
): Promise<DuplicateCheckResult> {
|
||||||
|
return await api.get<DuplicateCheckResult>('/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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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<string, unknown> | 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<SkuOriginRecord[]>([])
|
||||||
|
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<SkuOriginFilters>({
|
||||||
|
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<PaginatedData<SkuOriginRecord>>('/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<SkuOriginForm>) {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -7,6 +7,7 @@ export interface UserInfo {
|
|||||||
email: string
|
email: string
|
||||||
role: string
|
role: string
|
||||||
status: number
|
status: number
|
||||||
|
api_key_enabled?: boolean
|
||||||
ext?: Record<string, unknown> | null
|
ext?: Record<string, unknown> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -304,6 +304,30 @@ export interface OperationLogFilters {
|
|||||||
created_at_range: [string, string] | null
|
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 {
|
export class ApiError extends Error {
|
||||||
code: number
|
code: number
|
||||||
|
|||||||
Reference in New Issue
Block a user