feat(edit): 商品编辑页开发
This commit is contained in:
@@ -98,6 +98,7 @@ export class ExtensionService {
|
|||||||
product.rows[0].id,
|
product.rows[0].id,
|
||||||
1,
|
1,
|
||||||
payload,
|
payload,
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
status: 201,
|
status: 201,
|
||||||
@@ -191,6 +192,19 @@ export class ExtensionService {
|
|||||||
payload: CollectedProductPayload,
|
payload: CollectedProductPayload,
|
||||||
) {
|
) {
|
||||||
const { status, sourcePrice } = sourceState(payload);
|
const { status, sourcePrice } = sourceState(payload);
|
||||||
|
// Once a platform profile exists, product_variants are no longer a
|
||||||
|
// disposable collection projection: they may carry user edits and are
|
||||||
|
// referenced by platform listings. A re-collection refreshes the
|
||||||
|
// source evidence/candidates, but must not overwrite that draft or
|
||||||
|
// sever a SKU's publishing history.
|
||||||
|
const profileCount = await tx.query<{ count: string }>(
|
||||||
|
`SELECT count(*)::text AS count
|
||||||
|
FROM atlas.product_platform_profiles
|
||||||
|
WHERE product_id = $1`,
|
||||||
|
[productId],
|
||||||
|
);
|
||||||
|
const preserveEditorDraft =
|
||||||
|
Number(profileCount.rows[0]?.count ?? 0) > 0;
|
||||||
// 不删除旧候选/任务记录:运行中的 Worker 可能已拿到它。取消并保留审计记录,
|
// 不删除旧候选/任务记录:运行中的 Worker 可能已拿到它。取消并保留审计记录,
|
||||||
// 新快照在查询和 Worker 中均按 source_snapshot_revision 隔离。
|
// 新快照在查询和 Worker 中均按 source_snapshot_revision 隔离。
|
||||||
await tx.query(
|
await tx.query(
|
||||||
@@ -222,25 +236,14 @@ export class ExtensionService {
|
|||||||
const snapshotRevision = Number(item.rows[0]?.source_snapshot_revision);
|
const snapshotRevision = Number(item.rows[0]?.source_snapshot_revision);
|
||||||
if (!Number.isSafeInteger(snapshotRevision) || snapshotRevision < 1)
|
if (!Number.isSafeInteger(snapshotRevision) || snapshotRevision < 1)
|
||||||
throw new Error('采集来源快照版本更新失败');
|
throw new Error('采集来源快照版本更新失败');
|
||||||
|
if (!preserveEditorDraft) {
|
||||||
await tx.query(
|
await tx.query(
|
||||||
`UPDATE atlas.products SET source_title = $1, updated_at = now(), revision = revision + 1 WHERE id = $2`,
|
`UPDATE atlas.products
|
||||||
|
SET source_title = $1, updated_at = now(), revision = revision + 1
|
||||||
|
WHERE id = $2`,
|
||||||
[payload.common.title?.value ?? null, productId],
|
[payload.common.title?.value ?? null, productId],
|
||||||
);
|
);
|
||||||
// The historical task rows must remain auditable after replacing the
|
}
|
||||||
// source snapshot. Clear only their optional variant pointer before
|
|
||||||
// deleting old variants. The original composite FK used ON DELETE SET
|
|
||||||
// NULL and PostgreSQL tried to null both key columns, including the
|
|
||||||
// required workspace_id.
|
|
||||||
await tx.query(
|
|
||||||
`UPDATE atlas.collection_asset_transfers
|
|
||||||
SET source_variant_id = NULL, updated_at = now()
|
|
||||||
WHERE product_id = $1 AND source_variant_id IS NOT NULL`,
|
|
||||||
[productId],
|
|
||||||
);
|
|
||||||
await tx.query(
|
|
||||||
'DELETE FROM atlas.product_variants WHERE product_id = $1',
|
|
||||||
[productId],
|
|
||||||
);
|
|
||||||
await this.writeSourceSnapshotRows(
|
await this.writeSourceSnapshotRows(
|
||||||
tx,
|
tx,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -248,6 +251,7 @@ export class ExtensionService {
|
|||||||
productId,
|
productId,
|
||||||
snapshotRevision,
|
snapshotRevision,
|
||||||
payload,
|
payload,
|
||||||
|
preserveEditorDraft,
|
||||||
);
|
);
|
||||||
return snapshotRevision;
|
return snapshotRevision;
|
||||||
}
|
}
|
||||||
@@ -259,6 +263,7 @@ export class ExtensionService {
|
|||||||
productId: string,
|
productId: string,
|
||||||
snapshotRevision: number,
|
snapshotRevision: number,
|
||||||
payload: CollectedProductPayload,
|
payload: CollectedProductPayload,
|
||||||
|
preserveEditorDraft: boolean,
|
||||||
) {
|
) {
|
||||||
const verifiedVariants = payload.variants.filter(
|
const verifiedVariants = payload.variants.filter(
|
||||||
(variant) => variant.sourceVariantKey.trim().length > 0,
|
(variant) => variant.sourceVariantKey.trim().length > 0,
|
||||||
@@ -272,11 +277,64 @@ export class ExtensionService {
|
|||||||
displayName: '默认 SKU',
|
displayName: '默认 SKU',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
const sourceKeys = variantRows
|
||||||
|
.map((variant) => variant.sourceVariantKey)
|
||||||
|
.filter((key): key is string => !!key);
|
||||||
|
const existing = await tx.query<{
|
||||||
|
id: string;
|
||||||
|
source_variant_key: string | null;
|
||||||
|
deleted_at: Date | string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT id, source_variant_key, deleted_at
|
||||||
|
FROM atlas.product_variants
|
||||||
|
WHERE product_id = $1
|
||||||
|
AND (source_variant_key = ANY($2::text[]) OR source_variant_key IS NULL)
|
||||||
|
ORDER BY sort_order, id`,
|
||||||
|
[productId, sourceKeys],
|
||||||
|
);
|
||||||
|
const existingBySourceKey = new Map(
|
||||||
|
existing.rows
|
||||||
|
.filter((variant) => variant.source_variant_key)
|
||||||
|
.map((variant) => [variant.source_variant_key!, variant]),
|
||||||
|
);
|
||||||
|
// A capture without a stable source key has exactly one synthetic
|
||||||
|
// default SKU. Reuse an existing unkeyed row when possible; this keeps
|
||||||
|
// its platform mapping rather than attempting a duplicate insert.
|
||||||
|
const existingDefault = existing.rows.find(
|
||||||
|
(variant) => variant.source_variant_key === null,
|
||||||
|
);
|
||||||
const variants = new Map<string, string>();
|
const variants = new Map<string, string>();
|
||||||
|
const retainedVariantIds: string[] = [];
|
||||||
for (let index = 0; index < variantRows.length; index += 1) {
|
for (let index = 0; index < variantRows.length; index += 1) {
|
||||||
const variant = variantRows[index];
|
const variant = variantRows[index];
|
||||||
|
const matched = variant.sourceVariantKey
|
||||||
|
? existingBySourceKey.get(variant.sourceVariantKey)
|
||||||
|
: existingDefault;
|
||||||
|
let variantId: string;
|
||||||
|
if (matched) {
|
||||||
|
variantId = matched.id;
|
||||||
|
// A user may have deliberately removed an Atlas SKU after
|
||||||
|
// editing began. Keep that decision; its source evidence is
|
||||||
|
// still present in normalized_payload, but it is not silently
|
||||||
|
// revived into the editable/publishable SKU set.
|
||||||
|
if (!preserveEditorDraft) {
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.product_variants
|
||||||
|
SET source_sku = $1, display_name = $2, sort_order = $3,
|
||||||
|
deleted_at = NULL, updated_at = now()
|
||||||
|
WHERE id = $4`,
|
||||||
|
[
|
||||||
|
variant.sourceSku ?? null,
|
||||||
|
variant.displayName,
|
||||||
|
index,
|
||||||
|
variantId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
const inserted = await tx.query<{ id: string }>(
|
const inserted = await tx.query<{ id: string }>(
|
||||||
`INSERT INTO atlas.product_variants (workspace_id, product_id, source_variant_key, source_sku, display_name, sort_order)
|
`INSERT INTO atlas.product_variants
|
||||||
|
(workspace_id, product_id, source_variant_key, source_sku, display_name, sort_order)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||||
[
|
[
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -287,8 +345,31 @@ export class ExtensionService {
|
|||||||
index,
|
index,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
variantId = inserted.rows[0].id;
|
||||||
|
await this.ensureProfileListingsForVariant(
|
||||||
|
tx,
|
||||||
|
productId,
|
||||||
|
variantId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
retainedVariantIds.push(variantId);
|
||||||
if (variant.sourceVariantKey)
|
if (variant.sourceVariantKey)
|
||||||
variants.set(variant.sourceVariantKey, inserted.rows[0].id);
|
variants.set(variant.sourceVariantKey, variantId);
|
||||||
|
}
|
||||||
|
// Before an editor profile exists, variants are still just the latest
|
||||||
|
// source projection, so a missing source SKU can be safely hidden. Do
|
||||||
|
// not physically delete it: the partial source-key unique index does
|
||||||
|
// not exclude soft-deleted rows, and a later refresh may legitimately
|
||||||
|
// rediscover the same stable key. After editing begins, omission from a
|
||||||
|
// source refresh must never act as a user-requested SKU deletion.
|
||||||
|
if (!preserveEditorDraft) {
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.product_variants
|
||||||
|
SET deleted_at = now(), updated_at = now()
|
||||||
|
WHERE product_id = $1 AND deleted_at IS NULL
|
||||||
|
AND NOT (id = ANY($2::uuid[]))`,
|
||||||
|
[productId, retainedVariantIds],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for (const asset of payload.assets) {
|
for (const asset of payload.assets) {
|
||||||
await tx.query(
|
await tx.query(
|
||||||
@@ -323,6 +404,28 @@ export class ExtensionService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ensureProfileListingsForVariant(
|
||||||
|
tx: { query: Database['query'] },
|
||||||
|
productId: string,
|
||||||
|
variantId: string,
|
||||||
|
) {
|
||||||
|
// A source refresh may discover a new real SKU after a user has
|
||||||
|
// already entered one or more platform editors. Every existing profile
|
||||||
|
// needs a fresh, explicitly not-listed mapping for that SKU; imported
|
||||||
|
// mappings for other SKU rows are untouched.
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.product_variant_platform_listings
|
||||||
|
(workspace_id, product_id, profile_id, platform_id, product_variant_id, store_id, currency_code)
|
||||||
|
SELECT current_setting('app.workspace_id')::uuid, $1, p.id, p.platform_id,
|
||||||
|
$2, p.target_store_id, s.currency_code
|
||||||
|
FROM atlas.product_platform_profiles p
|
||||||
|
JOIN atlas.stores s ON s.id = p.target_store_id
|
||||||
|
WHERE p.product_id = $1
|
||||||
|
ON CONFLICT (workspace_id, profile_id, product_variant_id) DO NOTHING`,
|
||||||
|
[productId, variantId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async materialResponse(
|
private async materialResponse(
|
||||||
tx: { query: Database['query'] },
|
tx: { query: Database['query'] },
|
||||||
collectionItemId: string,
|
collectionItemId: string,
|
||||||
@@ -360,7 +463,9 @@ export class ExtensionService {
|
|||||||
editor_entry_path: `/collection-test/${collectionItemId}`,
|
editor_entry_path: `/collection-test/${collectionItemId}`,
|
||||||
materialization: {
|
materialization: {
|
||||||
total: Number(count.rows[0]?.total ?? 0),
|
total: Number(count.rows[0]?.total ?? 0),
|
||||||
pending_selection: Number(count.rows[0]?.pending_selection ?? 0),
|
pending_selection: Number(
|
||||||
|
count.rows[0]?.pending_selection ?? 0,
|
||||||
|
),
|
||||||
queued: Number(count.rows[0]?.queued ?? 0),
|
queued: Number(count.rows[0]?.queued ?? 0),
|
||||||
succeeded: Number(count.rows[0]?.succeeded ?? 0),
|
succeeded: Number(count.rows[0]?.succeeded ?? 0),
|
||||||
failed: Number(count.rows[0]?.failed ?? 0),
|
failed: Number(count.rows[0]?.failed ?? 0),
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
import { Body, Controller, Get, Headers, Inject, Param, Post, Put, Req, Res } from '@nestjs/common';
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Headers,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { saveProductEditorDraftRequestSchema, type Workspace } from '@atlas/contracts';
|
import {
|
||||||
|
saveProductEditorDraftRequestSchema,
|
||||||
|
type Workspace,
|
||||||
|
} from '@atlas/contracts';
|
||||||
import { ApiError } from '../common/api-error.js';
|
import { ApiError } from '../common/api-error.js';
|
||||||
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
||||||
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
|
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
|
||||||
@@ -50,6 +65,137 @@ export class ProductEditorController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('product-platform-profiles/:profileId/category-options')
|
||||||
|
async categoryOptions(
|
||||||
|
@Param('profileId') profileId: string,
|
||||||
|
@Query('q') query: string | undefined,
|
||||||
|
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
data: await this.editor.categoryOptions(
|
||||||
|
workspace.id,
|
||||||
|
requiredUuid('profile_id', profileId),
|
||||||
|
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||||
|
query,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('product-platform-profiles/:profileId/category-syncs')
|
||||||
|
async categorySync(
|
||||||
|
@Param('profileId') profileId: string,
|
||||||
|
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||||
|
@Headers('idempotency-key') key: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
) {
|
||||||
|
const result = await this.editor.requestCategoryTreeSync(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
requiredUuid('profile_id', profileId),
|
||||||
|
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||||
|
requiredUuid('Idempotency-Key', key),
|
||||||
|
);
|
||||||
|
response.status(result.status);
|
||||||
|
return result.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('product-platform-profiles/:profileId/attribute-schema')
|
||||||
|
async attributeSchema(
|
||||||
|
@Param('profileId') profileId: string,
|
||||||
|
@Query('category_id') categoryId: string | undefined,
|
||||||
|
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
data: await this.editor.attributeSchema(
|
||||||
|
workspace.id,
|
||||||
|
requiredUuid('profile_id', profileId),
|
||||||
|
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||||
|
optionalUuid('category_id', categoryId),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('product-platform-profiles/:profileId/attribute-schema-syncs')
|
||||||
|
async attributeSchemaSync(
|
||||||
|
@Param('profileId') profileId: string,
|
||||||
|
@Query('category_id') categoryId: string | undefined,
|
||||||
|
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||||
|
@Headers('idempotency-key') key: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
) {
|
||||||
|
const result = await this.editor.requestAttributeSchemaSync(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
requiredUuid('profile_id', profileId),
|
||||||
|
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||||
|
requiredUuid('Idempotency-Key', key),
|
||||||
|
requiredUuid('category_id', categoryId),
|
||||||
|
);
|
||||||
|
response.status(result.status);
|
||||||
|
return result.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(
|
||||||
|
'product-platform-profiles/:profileId/attribute-dictionaries/:dictionaryId',
|
||||||
|
)
|
||||||
|
async attributeDictionary(
|
||||||
|
@Param('profileId') profileId: string,
|
||||||
|
@Param('dictionaryId') dictionaryId: string,
|
||||||
|
@Query('q') query: string | undefined,
|
||||||
|
@Query('category_id') categoryId: string | undefined,
|
||||||
|
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
if (!dictionaryId.trim() || dictionaryId.length > 200)
|
||||||
|
throw new ApiError(400, 'VALIDATION_FAILED', '字典标识不合法');
|
||||||
|
return {
|
||||||
|
data: await this.editor.attributeDictionary(
|
||||||
|
workspace.id,
|
||||||
|
requiredUuid('profile_id', profileId),
|
||||||
|
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||||
|
dictionaryId,
|
||||||
|
query,
|
||||||
|
optionalUuid('category_id', categoryId),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(
|
||||||
|
'product-platform-profiles/:profileId/attribute-dictionaries/:dictionaryId/syncs',
|
||||||
|
)
|
||||||
|
async attributeDictionarySync(
|
||||||
|
@Param('profileId') profileId: string,
|
||||||
|
@Param('dictionaryId') dictionaryId: string,
|
||||||
|
@Query('category_id') categoryId: string | undefined,
|
||||||
|
@Query('q') query: string | undefined,
|
||||||
|
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||||
|
@Headers('idempotency-key') key: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
) {
|
||||||
|
if (!dictionaryId.trim() || dictionaryId.length > 200)
|
||||||
|
throw new ApiError(400, 'VALIDATION_FAILED', '字典标识不合法');
|
||||||
|
const result = await this.editor.requestAttributeDictionarySync(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
requiredUuid('profile_id', profileId),
|
||||||
|
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||||
|
requiredUuid('Idempotency-Key', key),
|
||||||
|
requiredUuid('category_id', categoryId),
|
||||||
|
dictionaryId,
|
||||||
|
query ?? '',
|
||||||
|
);
|
||||||
|
response.status(result.status);
|
||||||
|
return result.body;
|
||||||
|
}
|
||||||
|
|
||||||
@Put('products/:productId/editor-draft')
|
@Put('products/:productId/editor-draft')
|
||||||
async save(
|
async save(
|
||||||
@Param('productId') productId: string,
|
@Param('productId') productId: string,
|
||||||
@@ -62,11 +208,19 @@ export class ProductEditorController {
|
|||||||
) {
|
) {
|
||||||
const parsed = saveProductEditorDraftRequestSchema.safeParse(body);
|
const parsed = saveProductEditorDraftRequestSchema.safeParse(body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', parsed.error.issues.map((issue) => ({
|
throw new ApiError(
|
||||||
field: issue.path.join('.'), code: 'INVALID_FIELD', message: issue.message,
|
422,
|
||||||
})));
|
'VALIDATION_FAILED',
|
||||||
|
'请求参数不符合要求',
|
||||||
|
parsed.error.issues.map((issue) => ({
|
||||||
|
field: issue.path.join('.'),
|
||||||
|
code: 'INVALID_FIELD',
|
||||||
|
message: issue.message,
|
||||||
|
})),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return (await this.editor.save(
|
return (
|
||||||
|
await this.editor.save(
|
||||||
workspace.id,
|
workspace.id,
|
||||||
request.auth.userId,
|
request.auth.userId,
|
||||||
requiredUuid('product_id', productId),
|
requiredUuid('product_id', productId),
|
||||||
@@ -74,7 +228,8 @@ export class ProductEditorController {
|
|||||||
requiredUuid('Idempotency-Key', key),
|
requiredUuid('Idempotency-Key', key),
|
||||||
parseEditorRevision(ifMatch),
|
parseEditorRevision(ifMatch),
|
||||||
parsed.data,
|
parsed.data,
|
||||||
)).body;
|
)
|
||||||
|
).body;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,8 +240,18 @@ function requiredUuid(field: string, value: string | undefined) {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function optionalUuid(field: string, value: string | undefined) {
|
||||||
|
if (value === undefined || value === '') return undefined;
|
||||||
|
return requiredUuid(field, value);
|
||||||
|
}
|
||||||
|
|
||||||
function parseEditorRevision(value: string | undefined) {
|
function parseEditorRevision(value: string | undefined) {
|
||||||
const match = value?.match(/^"([1-9]\d*):([1-9]\d*)"$/);
|
const match = value?.match(/^"([1-9]\d*):([1-9]\d*)"$/);
|
||||||
if (!match) throw new ApiError(400, 'IF_MATCH_REQUIRED', '请求缺少或格式错误的 If-Match');
|
if (!match)
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
'IF_MATCH_REQUIRED',
|
||||||
|
'请求缺少或格式错误的 If-Match',
|
||||||
|
);
|
||||||
return { product: Number(match[1]), profile: Number(match[2]) };
|
return { product: Number(match[1]), profile: Number(match[2]) };
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -59,6 +59,17 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
|||||||
'DELETE FROM atlas.collection_asset_transfers WHERE workspace_id = $1',
|
'DELETE FROM atlas.collection_asset_transfers WHERE workspace_id = $1',
|
||||||
[workspaceId],
|
[workspaceId],
|
||||||
);
|
);
|
||||||
|
await tx.query(
|
||||||
|
'DELETE FROM atlas.product_variant_platform_listings WHERE workspace_id = $1',
|
||||||
|
[workspaceId],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
'DELETE FROM atlas.product_platform_profiles WHERE workspace_id = $1',
|
||||||
|
[workspaceId],
|
||||||
|
);
|
||||||
|
await tx.query('DELETE FROM atlas.stores WHERE workspace_id = $1', [
|
||||||
|
workspaceId,
|
||||||
|
]);
|
||||||
await tx.query(
|
await tx.query(
|
||||||
'DELETE FROM atlas.product_variants WHERE workspace_id = $1',
|
'DELETE FROM atlas.product_variants WHERE workspace_id = $1',
|
||||||
[workspaceId],
|
[workspaceId],
|
||||||
@@ -168,6 +179,62 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
|||||||
assert.equal(first.body.data.collection_item.status, 'ready_for_edit');
|
assert.equal(first.body.data.collection_item.status, 'ready_for_edit');
|
||||||
assert.equal(first.body.data.materialization.pending_selection, 1);
|
assert.equal(first.body.data.materialization.pending_selection, 1);
|
||||||
assert.equal(first.body.data.materialization.queued, 0);
|
assert.equal(first.body.data.materialization.queued, 0);
|
||||||
|
const platforms = await request('/api/platforms', {
|
||||||
|
headers: { Authorization: `Bearer ${webAccessToken}` },
|
||||||
|
});
|
||||||
|
const targetPlatform = platforms.body.data.find(
|
||||||
|
(platform: { code: string }) => platform.code === 'OZON',
|
||||||
|
);
|
||||||
|
assert.ok(targetPlatform);
|
||||||
|
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.stores
|
||||||
|
(workspace_id, platform_id, name, platform_client_id, currency_code,
|
||||||
|
is_default, status, credential_status)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, $1, '复采测试店铺',
|
||||||
|
$2, 'RUB', true, 'active', 'valid')`,
|
||||||
|
[targetPlatform.id, `refresh-${randomUUID()}`],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const editorContext = await request(
|
||||||
|
`/api/collection-items/${first.body.data.collection_item.id}/editor-contexts`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${webAccessToken}`,
|
||||||
|
'X-Atlas-Platform-Id': targetPlatform.id,
|
||||||
|
'Idempotency-Key': randomUUID(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(editorContext.response.status, 201);
|
||||||
|
// Simulate real draft work before the source page is collected again.
|
||||||
|
// Re-collection must neither delete this SKU nor overwrite its Atlas
|
||||||
|
// fields / platform mapping just because a newer source snapshot arrives.
|
||||||
|
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
const variant = await tx.query<{ id: string }>(
|
||||||
|
`SELECT id FROM atlas.product_variants
|
||||||
|
WHERE product_id = $1 AND source_variant_key = '123456789'`,
|
||||||
|
[first.body.data.product_id],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.products SET source_title = '人工确认商品名'
|
||||||
|
WHERE id = $1`,
|
||||||
|
[first.body.data.product_id],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.product_variants
|
||||||
|
SET display_name = '人工确认 SKU', purchase_cost_cny = '88.80'
|
||||||
|
WHERE id = $1`,
|
||||||
|
[variant.rows[0].id],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.product_variant_platform_listings
|
||||||
|
SET offer_id = 'manual-offer-001'
|
||||||
|
WHERE product_variant_id = $1`,
|
||||||
|
[variant.rows[0].id],
|
||||||
|
);
|
||||||
|
});
|
||||||
// A sibling Ozon URL has a different entered SKU. It must still reuse
|
// A sibling Ozon URL has a different entered SKU. It must still reuse
|
||||||
// the item because the submitted real-SKU sets overlap at 123456789.
|
// the item because the submitted real-SKU sets overlap at 123456789.
|
||||||
const refreshedPayload = {
|
const refreshedPayload = {
|
||||||
@@ -216,7 +283,8 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
|||||||
{
|
{
|
||||||
url: 'https://videos.example.test/refreshed-product.mp4',
|
url: 'https://videos.example.test/refreshed-product.mp4',
|
||||||
mediaType: 'video',
|
mediaType: 'video',
|
||||||
posterUrl: 'https://images.example.test/refreshed-video-poster.jpg',
|
posterUrl:
|
||||||
|
'https://images.example.test/refreshed-video-poster.jpg',
|
||||||
role: 'source_main',
|
role: 'source_main',
|
||||||
relation: 'product_candidate',
|
relation: 'product_candidate',
|
||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
@@ -241,6 +309,33 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
|||||||
);
|
);
|
||||||
assert.equal(second.body.data.materialization.pending_selection, 2);
|
assert.equal(second.body.data.materialization.pending_selection, 2);
|
||||||
assert.equal(second.body.data.materialization.queued, 0);
|
assert.equal(second.body.data.materialization.queued, 0);
|
||||||
|
const editorAfterRefresh = await request(
|
||||||
|
`/api/products/${first.body.data.product_id}/editor`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${webAccessToken}`,
|
||||||
|
'X-Atlas-Platform-Id': targetPlatform.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(editorAfterRefresh.response.status, 200);
|
||||||
|
assert.equal(
|
||||||
|
editorAfterRefresh.body.data.product.common.source_title,
|
||||||
|
'人工确认商品名',
|
||||||
|
);
|
||||||
|
const originalVariant = editorAfterRefresh.body.data.variants.find(
|
||||||
|
(variant: { source_variant_key: string }) =>
|
||||||
|
variant.source_variant_key === '123456789',
|
||||||
|
);
|
||||||
|
assert.equal(originalVariant.display_name, '人工确认 SKU');
|
||||||
|
assert.equal(originalVariant.purchase_cost_cny, '88.8000');
|
||||||
|
assert.equal(originalVariant.listing.offer_id, 'manual-offer-001');
|
||||||
|
const discoveredVariant = editorAfterRefresh.body.data.variants.find(
|
||||||
|
(variant: { source_variant_key: string }) =>
|
||||||
|
variant.source_variant_key === '987654321',
|
||||||
|
);
|
||||||
|
assert.ok(discoveredVariant);
|
||||||
|
assert.equal(discoveredVariant.listing.status, 'not_listed');
|
||||||
const inspection = await request(
|
const inspection = await request(
|
||||||
`/api/collection-items/${first.body.data.collection_item.id}`,
|
`/api/collection-items/${first.body.data.collection_item.id}`,
|
||||||
{ headers: { Authorization: `Bearer ${webAccessToken}` } },
|
{ headers: { Authorization: `Bearer ${webAccessToken}` } },
|
||||||
@@ -269,10 +364,17 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
|||||||
'pending_selection',
|
'pending_selection',
|
||||||
);
|
);
|
||||||
const videoTransfer = inspection.body.data.asset_transfers.find(
|
const videoTransfer = inspection.body.data.asset_transfers.find(
|
||||||
(transfer: { media_type: string }) => transfer.media_type === 'video',
|
(transfer: { media_type: string }) =>
|
||||||
|
transfer.media_type === 'video',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
videoTransfer?.source_url,
|
||||||
|
'https://videos.example.test/refreshed-product.mp4',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
videoTransfer?.poster_url,
|
||||||
|
'https://images.example.test/refreshed-video-poster.jpg',
|
||||||
);
|
);
|
||||||
assert.equal(videoTransfer?.source_url, 'https://videos.example.test/refreshed-product.mp4');
|
|
||||||
assert.equal(videoTransfer?.poster_url, 'https://images.example.test/refreshed-video-poster.jpg');
|
|
||||||
const eventsBeforeSelection = await database.workspaceTransaction(
|
const eventsBeforeSelection = await database.workspaceTransaction(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
async (tx) =>
|
async (tx) =>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+1
-10
@@ -14,17 +14,8 @@ function App() {
|
|||||||
colorPrimary: '#8b5cf6',
|
colorPrimary: '#8b5cf6',
|
||||||
// 全局圆角 10px,输入框/按钮/卡片等同步生效
|
// 全局圆角 10px,输入框/按钮/卡片等同步生效
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
// 全局正文字号 14px;输入类控件经 components 单独放大
|
// 全局正文字号沿用 Ant Design 默认值
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
controlHeight: 34,
|
|
||||||
controlHeightSM: 26,
|
|
||||||
},
|
|
||||||
components: {
|
|
||||||
// 文本框:文本 16px、高度同步加高;按钮与表格/菜单等保持全局默认
|
|
||||||
Input: { fontSize: 16, controlHeight: 40 },
|
|
||||||
Select: { fontSize: 16, controlHeight: 40 },
|
|
||||||
InputNumber: { fontSize: 16, controlHeight: 40 },
|
|
||||||
DatePicker: { fontSize: 16, controlHeight: 40 },
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,758 @@
|
|||||||
|
.product-editor {
|
||||||
|
max-width: 1500px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding-bottom: 92px;
|
||||||
|
}
|
||||||
|
.editor-header {
|
||||||
|
margin: -4px 0 20px;
|
||||||
|
}
|
||||||
|
.editor-back {
|
||||||
|
margin-left: -8px;
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
.editor-header-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.editor-header h2.ant-typography {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.editor-eyebrow {
|
||||||
|
color: #7c3aed;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.editor-header-context {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.editor-workspace {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 410px;
|
||||||
|
align-items: start;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.editor-main {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.editor-section.ant-card {
|
||||||
|
border: 1px solid #e9e7ef;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.025);
|
||||||
|
}
|
||||||
|
.editor-section .ant-card-body {
|
||||||
|
padding: 25px 28px;
|
||||||
|
}
|
||||||
|
.editor-section-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
.editor-section-heading h4.ant-typography {
|
||||||
|
margin: 2px 0 3px;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.editor-section-number {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
margin-right: 9px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #ede9fe;
|
||||||
|
color: #7c3aed;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
vertical-align: 2px;
|
||||||
|
}
|
||||||
|
.attribute-item span,
|
||||||
|
.field-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: #77718a;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.editor-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.editor-form-grid .span-full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.editor-form-grid .span-two {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
.editor-section .ant-form-item-label > label {
|
||||||
|
color: #344054;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.product-editor .ant-form-item-label > label {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.product-editor .ant-input,
|
||||||
|
.product-editor .ant-select-selection-item,
|
||||||
|
.product-editor .ant-select-selection-placeholder {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.product-editor .ant-input:not(textarea) {
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
.product-editor .ant-input-group-addon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.product-editor .ant-select .ant-select-selector {
|
||||||
|
min-height: 40px !important;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.product-editor .ant-select-single .ant-select-selector {
|
||||||
|
height: 40px !important;
|
||||||
|
}
|
||||||
|
.product-editor .ant-select-single .ant-select-selection-item,
|
||||||
|
.product-editor .ant-select-single .ant-select-selection-placeholder {
|
||||||
|
line-height: 38px !important;
|
||||||
|
}
|
||||||
|
.product-editor .ant-select-multiple .ant-select-selector {
|
||||||
|
padding-top: 3px;
|
||||||
|
padding-bottom: 3px;
|
||||||
|
}
|
||||||
|
.package-fields {
|
||||||
|
margin-top: 22px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #f0eef4;
|
||||||
|
}
|
||||||
|
.package-fields > .ant-form-item:first-child {
|
||||||
|
width: 150px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.package-dimensions {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.package-dimensions-controls {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120px 22px 120px 22px 120px auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
.package-dimension-separator {
|
||||||
|
color: #98a2b3;
|
||||||
|
font-size: 23px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.package-dimension-unit {
|
||||||
|
color: #667085;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 40px;
|
||||||
|
}
|
||||||
|
.category-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid #eae7f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fcfbfd;
|
||||||
|
}
|
||||||
|
.category-row strong {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
.attribute-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.attribute-item {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 11px 13px;
|
||||||
|
border: 1px solid #eeeaf3;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.attribute-item strong {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.editor-standard-select {
|
||||||
|
min-height: 40px !important;
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
.editor-standard-select .ant-select-content {
|
||||||
|
min-height: 38px;
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
.editor-standard-select .ant-select-content-has-value,
|
||||||
|
.editor-standard-select .ant-select-placeholder,
|
||||||
|
.editor-standard-select .ant-select-input {
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-select-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 4px 8px 6px;
|
||||||
|
border-top: 1px solid var(--ant-color-border-secondary, #f0f0f0);
|
||||||
|
}
|
||||||
|
.editor-standard-select.ant-select-single {
|
||||||
|
height: 40px !important;
|
||||||
|
}
|
||||||
|
.editor-standard-select.ant-select-single .ant-select-content {
|
||||||
|
height: 38px;
|
||||||
|
line-height: 38px;
|
||||||
|
}
|
||||||
|
.editor-standard-select.ant-select-single .ant-select-input {
|
||||||
|
height: 38px;
|
||||||
|
line-height: 38px !important;
|
||||||
|
}
|
||||||
|
.editor-standard-select.ant-select-multiple .ant-select-content {
|
||||||
|
min-height: 40px !important;
|
||||||
|
padding: 3px 0;
|
||||||
|
}
|
||||||
|
.editor-standard-select.ant-select-multiple .ant-select-content-item {
|
||||||
|
min-height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.editor-standard-select.ant-select-multiple .ant-select-placeholder,
|
||||||
|
.editor-standard-select.ant-select-multiple .ant-select-input {
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
.editor-standard-select.ant-select-multiple .ant-select-placeholder,
|
||||||
|
.editor-standard-select.ant-select-multiple .ant-select-input {
|
||||||
|
line-height: 32px !important;
|
||||||
|
}
|
||||||
|
.dynamic-attributes {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 22px;
|
||||||
|
border-top: 1px solid #eeeaf4;
|
||||||
|
}
|
||||||
|
.dynamic-attributes h5.ant-typography {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.dynamic-attribute-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.dynamic-attribute-item .ant-space-compact {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.dynamic-attribute-item .ant-btn-dangerous.ant-btn-text {
|
||||||
|
min-width: 56px;
|
||||||
|
}
|
||||||
|
.copy-subsection {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid #eeeaf4;
|
||||||
|
}
|
||||||
|
.copy-subsection .editor-section-heading {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.copy-store-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(240px, 1fr) 170px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.sku-section .ant-input {
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.sku-section textarea.ant-input {
|
||||||
|
min-height: 58px;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
.sku-section .ant-input-group-addon {
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.sku-section .ant-input-group .ant-input {
|
||||||
|
border-radius: 8px 0 0 8px;
|
||||||
|
}
|
||||||
|
.sku-section .ant-input:not(textarea) {
|
||||||
|
height: 32px;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
.sku-card-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.sku-card.ant-card {
|
||||||
|
border: 1px solid #e8e4ee;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.sku-card .ant-card-body {
|
||||||
|
padding: 16px 18px;
|
||||||
|
}
|
||||||
|
.sku-card-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
.sku-card-index {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #eee9fe;
|
||||||
|
color: #7151df;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.sku-card-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(210px, 1.55fr) 58px minmax(132px, 1fr) 104px 112px 82px auto;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: start;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.sku-card-summary .ant-form-item {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.sku-card-field,
|
||||||
|
.sku-card-value {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.sku-main-thumbnail {
|
||||||
|
display: flex;
|
||||||
|
width: 42px;
|
||||||
|
aspect-ratio: 3 / 4;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #ece8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f4f2f7;
|
||||||
|
color: #90889c;
|
||||||
|
font-size: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.sku-main-thumbnail .ant-image,
|
||||||
|
.sku-main-thumbnail img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.sku-main-thumbnail img {
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.sku-image-field {
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
.sku-card-field > span,
|
||||||
|
.sku-card-value > span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: #77718a;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.sku-card-value strong {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #40394f;
|
||||||
|
font-size: 14px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.sku-card-value strong {
|
||||||
|
min-height: 32px;
|
||||||
|
padding-top: 5px;
|
||||||
|
line-height: 22px;
|
||||||
|
}
|
||||||
|
.sku-expand-button {
|
||||||
|
align-self: start;
|
||||||
|
justify-self: end;
|
||||||
|
margin-top: 23px;
|
||||||
|
}
|
||||||
|
.sku-card-expanded {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.08fr) minmax(390px, 0.92fr);
|
||||||
|
gap: 32px;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #f0edf3;
|
||||||
|
}
|
||||||
|
.sku-media-panel h5.ant-typography,
|
||||||
|
.sku-spec-panel h5.ant-typography {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
.sku-media-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.sku-media-secondary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.sku-media-item {
|
||||||
|
min-width: 0;
|
||||||
|
aspect-ratio: 3 / 4;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #ece8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f5f3f7;
|
||||||
|
}
|
||||||
|
.sku-media-item .ant-image,
|
||||||
|
.sku-media-item img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.sku-media-item img {
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.sku-media-empty {
|
||||||
|
display: flex;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
min-height: 190px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px dashed #d9d4df;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #90889c;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.sku-spec-panel {
|
||||||
|
padding: 18px 18px 6px;
|
||||||
|
border: 1px solid #eeeaf3;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fcfbfd;
|
||||||
|
}
|
||||||
|
.sku-spec-panel .ant-form-item {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.sku-spec-panel .ant-form-item-label {
|
||||||
|
flex: 0 0 86px !important;
|
||||||
|
}
|
||||||
|
.sku-spec-panel .ant-form-item-label > label {
|
||||||
|
height: 32px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.sku-spec-panel .ant-form-item-control {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.sku-spec-panel > .ant-form-item:not(:has(.sku-inline-dimensions)) .ant-input-group-wrapper {
|
||||||
|
max-width: 220px;
|
||||||
|
}
|
||||||
|
.sku-inline-dimensions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(54px, 1fr) 16px minmax(54px, 1fr) 16px minmax(54px, 1fr) 28px;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.sku-dimension-separator {
|
||||||
|
color: #98a2b3;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.sku-dimension-unit {
|
||||||
|
color: #77718a;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.add-sku-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
.add-sku-button {
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
.editor-aside {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.collected-info-panel.ant-card {
|
||||||
|
border: 1px solid #e9e7ef;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.025);
|
||||||
|
}
|
||||||
|
.collected-info-panel .ant-card-body {
|
||||||
|
min-height: 820px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.collected-info-panel h4.ant-typography {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.collected-info-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 17px;
|
||||||
|
}
|
||||||
|
.collected-info-heading .ant-typography-secondary {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.source-update-alert {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.source-update-alert .ant-alert-description {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.collected-info-panel .ant-input {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.collected-info-panel .ant-input:not(textarea) {
|
||||||
|
height: 32px;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
.collected-field {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.collected-field:first-of-type {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.collected-field > span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
color: #77718a;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.collected-field-label {
|
||||||
|
display: flex;
|
||||||
|
min-height: 24px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
color: #77718a;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.collected-field-label .ant-btn {
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.source-diff-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.source-diff-list strong,
|
||||||
|
.source-diff-list span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.source-diff-list span {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: #667085;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.collected-field > strong {
|
||||||
|
display: block;
|
||||||
|
color: #352f43;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.collected-field .ant-typography {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
color: #50485d;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
.collected-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #ece9f1;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.collected-summary > div {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-right: 1px solid #f0edf3;
|
||||||
|
border-bottom: 1px solid #f0edf3;
|
||||||
|
}
|
||||||
|
.collected-summary > div:nth-child(2n) {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
.collected-summary > div:nth-last-child(-n + 2) {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.collected-summary b {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
color: #77718a;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.collected-summary strong {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #352f43;
|
||||||
|
font-size: 13px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.collected-attributes {
|
||||||
|
border: 1px solid #ece9f1;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.collected-attributes > div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(100px, 0.9fr) minmax(0, 1.3fr);
|
||||||
|
gap: 12px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-bottom: 1px solid #f0edf3;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.collected-attributes > div:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.collected-attributes b {
|
||||||
|
color: #6b6376;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.collected-attributes span {
|
||||||
|
color: #352f43;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.other-section .editor-section-heading {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.editor-footer {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 13px 16px;
|
||||||
|
border: 1px solid #e4e0eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
box-shadow: 0 -8px 24px rgba(38, 27, 56, 0.08);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
.footer-ai-actions {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.editor-workspace {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.editor-aside {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
.collected-info-panel {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.collected-info-panel .ant-card-body {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.editor-header-main,
|
||||||
|
.editor-section-heading,
|
||||||
|
.editor-footer {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.editor-form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.editor-form-grid .span-two {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
.package-fields > .ant-form-item:first-child,
|
||||||
|
.package-dimensions-controls {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.package-dimensions-controls {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.package-dimension-separator {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.copy-store-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.attribute-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.sku-card-summary {
|
||||||
|
grid-template-columns: minmax(150px, 1fr) 54px repeat(2, minmax(115px, 1fr)) auto;
|
||||||
|
}
|
||||||
|
.sku-card-value {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.sku-card-expanded {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.editor-section .ant-card-body {
|
||||||
|
padding: 20px 16px;
|
||||||
|
}
|
||||||
|
.category-row,
|
||||||
|
.editor-footer {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.category-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.attribute-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.sku-card-summary {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 48px auto;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.sku-card-field {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.sku-main-thumbnail {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
.sku-media-grid {
|
||||||
|
grid-auto-rows: 72px;
|
||||||
|
}
|
||||||
|
.sku-inline-dimensions {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.sku-inline-dimensions > span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.editor-footer .ant-space {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.editor-footer .ant-space-item {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.editor-footer .ant-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,10 @@
|
|||||||
import type {
|
import type {
|
||||||
|
ProductAttributeDictionaryValue,
|
||||||
|
ProductAttributeSchema,
|
||||||
|
ProductEditorCategoryOption,
|
||||||
ProductEditorContext,
|
ProductEditorContext,
|
||||||
ProductEditorSnapshot,
|
ProductEditorSnapshot,
|
||||||
|
Operation,
|
||||||
SaveProductEditorDraftRequest,
|
SaveProductEditorDraftRequest,
|
||||||
SaveProductEditorDraftResponse,
|
SaveProductEditorDraftResponse,
|
||||||
} from '@atlas/contracts';
|
} from '@atlas/contracts';
|
||||||
@@ -8,9 +12,16 @@ import { api } from './api';
|
|||||||
|
|
||||||
export const productEditorApi = {
|
export const productEditorApi = {
|
||||||
enter: (collectionItemId: string, platformId: string) =>
|
enter: (collectionItemId: string, platformId: string) =>
|
||||||
api.post<ProductEditorContext>(`/collection-items/${collectionItemId}/editor-contexts`, undefined, {
|
api.post<ProductEditorContext>(
|
||||||
headers: { 'X-Atlas-Platform-Id': platformId, 'Idempotency-Key': crypto.randomUUID() },
|
`/collection-items/${collectionItemId}/editor-contexts`,
|
||||||
}),
|
undefined,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'X-Atlas-Platform-Id': platformId,
|
||||||
|
'Idempotency-Key': crypto.randomUUID(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
read: (productId: string, platformId: string) =>
|
read: (productId: string, platformId: string) =>
|
||||||
api.get<ProductEditorSnapshot>(`/products/${productId}/editor`, {
|
api.get<ProductEditorSnapshot>(`/products/${productId}/editor`, {
|
||||||
headers: { 'X-Atlas-Platform-Id': platformId },
|
headers: { 'X-Atlas-Platform-Id': platformId },
|
||||||
@@ -19,12 +30,88 @@ export const productEditorApi = {
|
|||||||
productId: string,
|
productId: string,
|
||||||
platformId: string,
|
platformId: string,
|
||||||
revisions: { product: number; profile: number },
|
revisions: { product: number; profile: number },
|
||||||
payload: SaveProductEditorDraftRequest,
|
payload: SaveProductEditorDraftRequest
|
||||||
) => api.put<SaveProductEditorDraftResponse>(`/products/${productId}/editor-draft`, payload, {
|
) =>
|
||||||
|
api.put<SaveProductEditorDraftResponse>(`/products/${productId}/editor-draft`, payload, {
|
||||||
headers: {
|
headers: {
|
||||||
'X-Atlas-Platform-Id': platformId,
|
'X-Atlas-Platform-Id': platformId,
|
||||||
'Idempotency-Key': crypto.randomUUID(),
|
'Idempotency-Key': crypto.randomUUID(),
|
||||||
'If-Match': `"${revisions.product}:${revisions.profile}"`,
|
'If-Match': `"${revisions.product}:${revisions.profile}"`,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
categoryOptions: (profileId: string, platformId: string, search?: string) =>
|
||||||
|
api.get<ProductEditorCategoryOption[]>(
|
||||||
|
`/product-platform-profiles/${profileId}/category-options`,
|
||||||
|
{
|
||||||
|
params: search ? { q: search } : undefined,
|
||||||
|
headers: { 'X-Atlas-Platform-Id': platformId },
|
||||||
|
}
|
||||||
|
),
|
||||||
|
syncCategoryTree: (profileId: string, platformId: string) =>
|
||||||
|
api.post<{ operation: Operation }>(
|
||||||
|
`/product-platform-profiles/${profileId}/category-syncs`,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'X-Atlas-Platform-Id': platformId,
|
||||||
|
'Idempotency-Key': crypto.randomUUID(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
attributeSchema: (profileId: string, platformId: string, categoryId?: string | null) =>
|
||||||
|
api.get<ProductAttributeSchema[]>(
|
||||||
|
`/product-platform-profiles/${profileId}/attribute-schema`,
|
||||||
|
{
|
||||||
|
params: categoryId ? { category_id: categoryId } : undefined,
|
||||||
|
headers: { 'X-Atlas-Platform-Id': platformId },
|
||||||
|
}
|
||||||
|
),
|
||||||
|
syncAttributeSchema: (profileId: string, platformId: string, categoryId: string) =>
|
||||||
|
api.post<{ operation: Operation }>(
|
||||||
|
`/product-platform-profiles/${profileId}/attribute-schema-syncs`,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
params: { category_id: categoryId },
|
||||||
|
headers: {
|
||||||
|
'X-Atlas-Platform-Id': platformId,
|
||||||
|
'Idempotency-Key': crypto.randomUUID(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
attributeDictionary: (
|
||||||
|
profileId: string,
|
||||||
|
platformId: string,
|
||||||
|
dictionaryId: string,
|
||||||
|
search?: string,
|
||||||
|
categoryId?: string | null
|
||||||
|
) =>
|
||||||
|
api.get<{ items: ProductAttributeDictionaryValue[]; next_cursor: string | null }>(
|
||||||
|
`/product-platform-profiles/${profileId}/attribute-dictionaries/${encodeURIComponent(dictionaryId)}`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
...(categoryId ? { category_id: categoryId } : {}),
|
||||||
|
...(search ? { q: search } : {}),
|
||||||
|
},
|
||||||
|
headers: { 'X-Atlas-Platform-Id': platformId },
|
||||||
|
}
|
||||||
|
),
|
||||||
|
syncAttributeDictionary: (
|
||||||
|
profileId: string,
|
||||||
|
platformId: string,
|
||||||
|
dictionaryId: string,
|
||||||
|
categoryId: string,
|
||||||
|
query: string
|
||||||
|
) =>
|
||||||
|
api.post<{ operation: Operation }>(
|
||||||
|
`/product-platform-profiles/${profileId}/attribute-dictionaries/${encodeURIComponent(dictionaryId)}/syncs`,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
params: { category_id: categoryId, q: query },
|
||||||
|
headers: {
|
||||||
|
'X-Atlas-Platform-Id': platformId,
|
||||||
|
'Idempotency-Key': crypto.randomUUID(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
operation: (operationId: string) => api.get<Operation>(`/operations/${operationId}`),
|
||||||
};
|
};
|
||||||
|
|||||||
+32
-1
@@ -13,6 +13,11 @@ import {
|
|||||||
import { decryptStoreSecret } from './store-secret.crypto.js';
|
import { decryptStoreSecret } from './store-secret.crypto.js';
|
||||||
import { MediaUploadCleanup } from './media-upload-cleanup.js';
|
import { MediaUploadCleanup } from './media-upload-cleanup.js';
|
||||||
import { materializeCollectionAssets } from './collection-assets.js';
|
import { materializeCollectionAssets } from './collection-assets.js';
|
||||||
|
import {
|
||||||
|
markCatalogSyncUnavailable,
|
||||||
|
syncOzonCatalog,
|
||||||
|
type CatalogSyncScope,
|
||||||
|
} from './ozon-catalog-sync.js';
|
||||||
|
|
||||||
const connection = new Redis(workerConfig.redisUrl, {
|
const connection = new Redis(workerConfig.redisUrl, {
|
||||||
maxRetriesPerRequest: null,
|
maxRetriesPerRequest: null,
|
||||||
@@ -35,6 +40,18 @@ const objectStorage = new S3Client({
|
|||||||
});
|
});
|
||||||
const mediaUploadCleanup = new MediaUploadCleanup(database, objectStorage);
|
const mediaUploadCleanup = new MediaUploadCleanup(database, objectStorage);
|
||||||
type StoreVerificationJob = OutboxJob & { payload: { store_id: string; operation_id: string; secret_id?: string } };
|
type StoreVerificationJob = OutboxJob & { payload: { store_id: string; operation_id: string; secret_id?: string } };
|
||||||
|
type CatalogSyncJob = OutboxJob & {
|
||||||
|
payload: {
|
||||||
|
store_id: string;
|
||||||
|
secret_id: string;
|
||||||
|
operation_id: string;
|
||||||
|
platform_id: string;
|
||||||
|
scope: CatalogSyncScope;
|
||||||
|
category_id?: string;
|
||||||
|
dictionary_id?: string;
|
||||||
|
query?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const worker = new Worker<OutboxJob>(
|
const worker = new Worker<OutboxJob>(
|
||||||
'atlas-dispatch',
|
'atlas-dispatch',
|
||||||
@@ -45,6 +62,12 @@ const worker = new Worker<OutboxJob>(
|
|||||||
await cleanupCollection(database, job.data as OutboxJob & { payload: { operation_id: string; collection_item_ids: string[] } });
|
await cleanupCollection(database, job.data as OutboxJob & { payload: { operation_id: string; collection_item_ids: string[] } });
|
||||||
} else if (job.name === 'collection.assets.materialize.requested' || job.name === 'collection.assets.retry.requested') {
|
} else if (job.name === 'collection.assets.materialize.requested' || job.name === 'collection.assets.retry.requested') {
|
||||||
await materializeCollectionAssets(database, objectStorage, workerConfig.objectStorageBucket, job.data);
|
await materializeCollectionAssets(database, objectStorage, workerConfig.objectStorageBucket, job.data);
|
||||||
|
} else if (
|
||||||
|
job.name === 'platform_category_tree_sync.requested' ||
|
||||||
|
job.name === 'platform_category_attribute_sync.requested' ||
|
||||||
|
job.name === 'platform_attribute_dictionary_sync.requested'
|
||||||
|
) {
|
||||||
|
await syncOzonCatalog(database, job.data as CatalogSyncJob);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ connection, concurrency: 5 },
|
{ connection, concurrency: 5 },
|
||||||
@@ -59,8 +82,16 @@ async function cleanupCollection(database: Database, job: OutboxJob & { payload:
|
|||||||
}
|
}
|
||||||
|
|
||||||
worker.on('failed', (job, error) => {
|
worker.on('failed', (job, error) => {
|
||||||
if (!job || job.attemptsMade < (job.opts.attempts ?? 1) || job.name !== 'store_credential_verification.requested') return;
|
if (!job || job.attemptsMade < (job.opts.attempts ?? 1)) return;
|
||||||
|
if (job.name === 'store_credential_verification.requested') {
|
||||||
void markUnavailable(database, job.data as StoreVerificationJob, error);
|
void markUnavailable(database, job.data as StoreVerificationJob, error);
|
||||||
|
} else if (
|
||||||
|
job.name === 'platform_category_tree_sync.requested' ||
|
||||||
|
job.name === 'platform_category_attribute_sync.requested' ||
|
||||||
|
job.name === 'platform_attribute_dictionary_sync.requested'
|
||||||
|
) {
|
||||||
|
void markCatalogSyncUnavailable(database, job.data as CatalogSyncJob, error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function verifyStoreCredentials(database: Database, job: StoreVerificationJob) {
|
async function verifyStoreCredentials(database: Database, job: StoreVerificationJob) {
|
||||||
|
|||||||
@@ -0,0 +1,499 @@
|
|||||||
|
import type { Database } from '@atlas/database';
|
||||||
|
import type { OutboxJob } from './outbox-dispatcher.js';
|
||||||
|
import {
|
||||||
|
fetchOzonCategoryAttributes,
|
||||||
|
fetchOzonCategoryTree,
|
||||||
|
fetchOzonDictionaryValues,
|
||||||
|
OzonCredentialError,
|
||||||
|
OzonRequestError,
|
||||||
|
type OzonCategoryAttribute,
|
||||||
|
type OzonCategoryNode,
|
||||||
|
type OzonDictionaryValue,
|
||||||
|
} from './ozon-client.js';
|
||||||
|
import { decryptStoreSecret } from './store-secret.crypto.js';
|
||||||
|
import { workerConfig } from './config.js';
|
||||||
|
|
||||||
|
export type CatalogSyncScope = 'tree' | 'attributes' | 'dictionary';
|
||||||
|
type CatalogSyncJob = OutboxJob & {
|
||||||
|
payload: {
|
||||||
|
store_id: string;
|
||||||
|
secret_id: string;
|
||||||
|
operation_id: string;
|
||||||
|
platform_id: string;
|
||||||
|
scope: CatalogSyncScope;
|
||||||
|
category_id?: string;
|
||||||
|
dictionary_id?: string;
|
||||||
|
query?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type SyncSource = {
|
||||||
|
platform_code: string;
|
||||||
|
platform_client_id: string;
|
||||||
|
secret_ciphertext: Buffer;
|
||||||
|
secret_nonce: Buffer;
|
||||||
|
};
|
||||||
|
type CategorySource = {
|
||||||
|
external_category_id: string;
|
||||||
|
external_type_id: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps platform metadata global while every credential and operation lookup
|
||||||
|
* remains inside the requesting workspace. Neither queue payloads nor cache
|
||||||
|
* rows contain the decrypted API key.
|
||||||
|
*/
|
||||||
|
export async function syncOzonCatalog(database: Database, job: CatalogSyncJob) {
|
||||||
|
const source = await loadSource(database, job);
|
||||||
|
if (!source) return cancelStaleOperation(database, job);
|
||||||
|
if (source.platform_code.toUpperCase() !== 'OZON') {
|
||||||
|
return failOperation(
|
||||||
|
database,
|
||||||
|
job,
|
||||||
|
'PLATFORM_CATALOG_UNSUPPORTED',
|
||||||
|
'当前平台暂不支持类目缓存同步',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = decryptStoreSecret(
|
||||||
|
source.secret_ciphertext,
|
||||||
|
source.secret_nonce,
|
||||||
|
workerConfig.storeSecretEncryptionKey,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
let result: Record<string, unknown>;
|
||||||
|
if (job.payload.scope === 'tree') {
|
||||||
|
const nodes = await fetchOzonCategoryTree(source.platform_client_id, apiKey);
|
||||||
|
result = await persistCategoryTree(database, job.payload.platform_id, nodes);
|
||||||
|
} else {
|
||||||
|
const category = await requireCategory(
|
||||||
|
database,
|
||||||
|
job.payload.platform_id,
|
||||||
|
job.payload.category_id,
|
||||||
|
);
|
||||||
|
if (!category.external_type_id) {
|
||||||
|
return failOperation(
|
||||||
|
database,
|
||||||
|
job,
|
||||||
|
'CATEGORY_TYPE_UNAVAILABLE',
|
||||||
|
'该类目缺少 Ozon 商品类型,无法同步属性',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (job.payload.scope === 'attributes') {
|
||||||
|
const attributes = await fetchOzonCategoryAttributes(
|
||||||
|
source.platform_client_id,
|
||||||
|
apiKey,
|
||||||
|
category.external_category_id,
|
||||||
|
category.external_type_id,
|
||||||
|
);
|
||||||
|
result = await persistAttributes(
|
||||||
|
database,
|
||||||
|
job.payload.platform_id,
|
||||||
|
job.payload.category_id ?? '',
|
||||||
|
attributes,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const dictionary = await requireDictionaryAttribute(
|
||||||
|
database,
|
||||||
|
job.payload.platform_id,
|
||||||
|
job.payload.category_id,
|
||||||
|
job.payload.dictionary_id,
|
||||||
|
);
|
||||||
|
const values = await fetchOzonDictionaryValues(
|
||||||
|
source.platform_client_id,
|
||||||
|
apiKey,
|
||||||
|
{
|
||||||
|
attributeId: dictionary.platform_attribute_id,
|
||||||
|
categoryId: category.external_category_id,
|
||||||
|
typeId: category.external_type_id,
|
||||||
|
query: job.payload.query,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
result = await persistDictionaryValues(
|
||||||
|
database,
|
||||||
|
job.payload.platform_id,
|
||||||
|
job.payload.dictionary_id ?? '',
|
||||||
|
values,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await succeedOperation(database, job, result);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof OzonCredentialError) {
|
||||||
|
await failOperation(
|
||||||
|
database,
|
||||||
|
job,
|
||||||
|
error.status === 403
|
||||||
|
? 'OZON_CATALOG_CREDENTIAL_FORBIDDEN'
|
||||||
|
: 'OZON_CATALOG_CREDENTIAL_UNAUTHORIZED',
|
||||||
|
error.status === 403
|
||||||
|
? 'Ozon 拒绝读取类目:请检查 API Key 权限'
|
||||||
|
: 'Ozon 未接受当前门店凭据',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error instanceof OzonRequestError) {
|
||||||
|
await failOperation(
|
||||||
|
database,
|
||||||
|
job,
|
||||||
|
'OZON_CATALOG_REQUEST_REJECTED',
|
||||||
|
`Ozon 拒绝类目同步请求(HTTP ${error.status})`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markCatalogSyncUnavailable(
|
||||||
|
database: Database,
|
||||||
|
job: CatalogSyncJob,
|
||||||
|
_error: Error,
|
||||||
|
) {
|
||||||
|
await failOperation(
|
||||||
|
database,
|
||||||
|
job,
|
||||||
|
'OZON_CATALOG_SYNC_UNAVAILABLE',
|
||||||
|
'暂时无法同步 Ozon 类目数据,请稍后重试',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSource(database: Database, job: CatalogSyncJob) {
|
||||||
|
return database.workspaceTransaction(job.workspaceId, async (tx) => {
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.operations SET status = 'running', started_at = coalesce(started_at, now()),
|
||||||
|
attempt_count = attempt_count + 1
|
||||||
|
WHERE id = $1 AND status IN ('queued', 'running')`,
|
||||||
|
[job.payload.operation_id],
|
||||||
|
);
|
||||||
|
const source = await tx.query<SyncSource>(
|
||||||
|
`SELECT platform.code AS platform_code, store.platform_client_id,
|
||||||
|
secret.secret_ciphertext, secret.secret_nonce
|
||||||
|
FROM atlas.operations operation
|
||||||
|
JOIN atlas.stores store ON store.id = $2 AND store.deleted_at IS NULL
|
||||||
|
JOIN atlas.platforms platform ON platform.id = store.platform_id AND platform.id = $3
|
||||||
|
JOIN atlas.store_secrets secret ON secret.id = $4 AND secret.store_id = store.id
|
||||||
|
WHERE operation.id = $1 AND operation.kind IN (
|
||||||
|
'platform_category_tree_sync',
|
||||||
|
'platform_category_attribute_sync',
|
||||||
|
'platform_attribute_dictionary_sync'
|
||||||
|
)
|
||||||
|
AND store.credential_status = 'valid'
|
||||||
|
AND secret.kind = 'seller_api_key' AND secret.revoked_at IS NULL
|
||||||
|
LIMIT 1`,
|
||||||
|
[
|
||||||
|
job.payload.operation_id,
|
||||||
|
job.payload.store_id,
|
||||||
|
job.payload.platform_id,
|
||||||
|
job.payload.secret_id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return source.rows[0] ?? null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelStaleOperation(database: Database, job: CatalogSyncJob) {
|
||||||
|
await database.workspaceTransaction(job.workspaceId, (tx) =>
|
||||||
|
tx.query(
|
||||||
|
`UPDATE atlas.operations SET status = 'cancelled', completed_at = now(),
|
||||||
|
error_code = 'CATALOG_SYNC_SOURCE_REPLACED',
|
||||||
|
error_message = '门店凭据或发布配置已变化,旧类目同步已取消'
|
||||||
|
WHERE id = $1 AND status IN ('queued', 'running')`,
|
||||||
|
[job.payload.operation_id],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function succeedOperation(
|
||||||
|
database: Database,
|
||||||
|
job: CatalogSyncJob,
|
||||||
|
result: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
await database.workspaceTransaction(job.workspaceId, (tx) =>
|
||||||
|
tx.query(
|
||||||
|
`UPDATE atlas.operations SET status = 'succeeded', completed_at = now(),
|
||||||
|
result = $2::jsonb, error_code = NULL, error_message = NULL
|
||||||
|
WHERE id = $1 AND status IN ('queued', 'running')`,
|
||||||
|
[job.payload.operation_id, JSON.stringify({ scope: job.payload.scope, ...result })],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function failOperation(
|
||||||
|
database: Database,
|
||||||
|
job: CatalogSyncJob,
|
||||||
|
code: string,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
await database.workspaceTransaction(job.workspaceId, (tx) =>
|
||||||
|
tx.query(
|
||||||
|
`UPDATE atlas.operations SET status = 'failed', completed_at = now(),
|
||||||
|
error_code = $2, error_message = $3
|
||||||
|
WHERE id = $1 AND status IN ('queued', 'running')`,
|
||||||
|
[job.payload.operation_id, code, message],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireCategory(
|
||||||
|
database: Database,
|
||||||
|
platformId: string,
|
||||||
|
categoryId: string | undefined,
|
||||||
|
) {
|
||||||
|
if (!categoryId) throw new Error('Catalog sync category is missing');
|
||||||
|
const result = await database.query<CategorySource>(
|
||||||
|
`SELECT external_category_id, external_type_id
|
||||||
|
FROM atlas.platform_product_categories
|
||||||
|
WHERE id = $1 AND platform_id = $2 AND is_active`,
|
||||||
|
[categoryId, platformId],
|
||||||
|
);
|
||||||
|
if (!result.rows[0]) throw new Error('Catalog sync category no longer exists');
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireDictionaryAttribute(
|
||||||
|
database: Database,
|
||||||
|
platformId: string,
|
||||||
|
categoryId: string | undefined,
|
||||||
|
dictionaryId: string | undefined,
|
||||||
|
) {
|
||||||
|
if (!categoryId || !dictionaryId) throw new Error('Catalog dictionary input is missing');
|
||||||
|
const result = await database.query<{ platform_attribute_id: string }>(
|
||||||
|
`SELECT platform_attribute_id
|
||||||
|
FROM atlas.platform_product_category_attributes
|
||||||
|
WHERE platform_id = $1 AND platform_product_category_id = $2 AND dictionary_id = $3
|
||||||
|
LIMIT 1`,
|
||||||
|
[platformId, categoryId, dictionaryId],
|
||||||
|
);
|
||||||
|
if (!result.rows[0]) throw new Error('Catalog dictionary is no longer valid for this category');
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
type FlatCategory = {
|
||||||
|
external_category_id: string;
|
||||||
|
external_type_id: string;
|
||||||
|
parent_key: string | null;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
is_active: boolean;
|
||||||
|
source_payload: OzonCategoryNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function persistCategoryTree(
|
||||||
|
database: Database,
|
||||||
|
platformId: string,
|
||||||
|
roots: OzonCategoryNode[],
|
||||||
|
) {
|
||||||
|
// Ozon 的树偶尔会在不同分支重复返回同一个类目;缓存主键是
|
||||||
|
// (category_id, type_id),因此保留最后一个完整路径而不让批量 upsert 冲突。
|
||||||
|
const records = [
|
||||||
|
...new Map(
|
||||||
|
flattenCategories(roots).map((record) => [
|
||||||
|
categoryKey(record.external_category_id, record.external_type_id),
|
||||||
|
record,
|
||||||
|
]),
|
||||||
|
).values(),
|
||||||
|
];
|
||||||
|
const syncTime = new Date();
|
||||||
|
await database.transaction(async (tx) => {
|
||||||
|
await tx.query(
|
||||||
|
`WITH incoming AS (
|
||||||
|
SELECT * FROM jsonb_to_recordset($2::jsonb) AS value(
|
||||||
|
external_category_id text, external_type_id text, parent_key text,
|
||||||
|
name text, path text, is_active boolean, source_payload jsonb
|
||||||
|
)
|
||||||
|
)
|
||||||
|
INSERT INTO atlas.platform_product_categories
|
||||||
|
(platform_id, external_category_id, external_type_id, name, path,
|
||||||
|
is_active, source_payload, synced_at)
|
||||||
|
SELECT $1, external_category_id, external_type_id, name, path,
|
||||||
|
is_active, source_payload, $3::timestamptz
|
||||||
|
FROM incoming
|
||||||
|
ON CONFLICT (platform_id, external_category_id, external_type_id) DO UPDATE
|
||||||
|
SET name = EXCLUDED.name, path = EXCLUDED.path, is_active = EXCLUDED.is_active,
|
||||||
|
source_payload = EXCLUDED.source_payload, synced_at = EXCLUDED.synced_at,
|
||||||
|
updated_at = now()`,
|
||||||
|
[platformId, JSON.stringify(records), syncTime],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`WITH incoming AS (
|
||||||
|
SELECT * FROM jsonb_to_recordset($2::jsonb) AS value(
|
||||||
|
external_category_id text, external_type_id text, parent_key text,
|
||||||
|
name text, path text, is_active boolean, source_payload jsonb
|
||||||
|
)
|
||||||
|
), resolved AS (
|
||||||
|
SELECT child.id AS child_id, parent.id AS parent_id
|
||||||
|
FROM incoming
|
||||||
|
JOIN atlas.platform_product_categories child
|
||||||
|
ON child.platform_id = $1
|
||||||
|
AND child.external_category_id = incoming.external_category_id
|
||||||
|
AND child.external_type_id = incoming.external_type_id
|
||||||
|
LEFT JOIN atlas.platform_product_categories parent
|
||||||
|
ON parent.platform_id = $1
|
||||||
|
AND parent.external_category_id || ':' || parent.external_type_id = incoming.parent_key
|
||||||
|
)
|
||||||
|
UPDATE atlas.platform_product_categories category
|
||||||
|
SET parent_id = resolved.parent_id, updated_at = now()
|
||||||
|
FROM resolved WHERE category.id = resolved.child_id`,
|
||||||
|
[platformId, JSON.stringify(records)],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.platform_product_categories SET is_active = false, updated_at = now()
|
||||||
|
WHERE platform_id = $1 AND synced_at IS DISTINCT FROM $2::timestamptz`,
|
||||||
|
[platformId, syncTime],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
category_count: records.length,
|
||||||
|
active_category_count: records.filter((record) => record.is_active).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenCategories(
|
||||||
|
nodes: OzonCategoryNode[],
|
||||||
|
path: string[] = [],
|
||||||
|
parentKey: string | null = null,
|
||||||
|
): FlatCategory[] {
|
||||||
|
const flattened: FlatCategory[] = [];
|
||||||
|
for (const node of nodes) {
|
||||||
|
const categoryId = String(node.description_category_id ?? '').trim();
|
||||||
|
if (!categoryId || !node.category_name?.trim()) continue;
|
||||||
|
const typeId = String(node.type_id ?? 0);
|
||||||
|
const nextPath = [...path, node.category_name.trim()];
|
||||||
|
const key = categoryKey(categoryId, typeId);
|
||||||
|
flattened.push({
|
||||||
|
external_category_id: categoryId,
|
||||||
|
external_type_id: typeId,
|
||||||
|
parent_key: parentKey,
|
||||||
|
name: node.category_name.trim(),
|
||||||
|
path: nextPath.join(' › '),
|
||||||
|
is_active: node.disabled !== true && typeId !== '0',
|
||||||
|
source_payload: node,
|
||||||
|
});
|
||||||
|
flattened.push(...flattenCategories(node.children ?? [], nextPath, key));
|
||||||
|
}
|
||||||
|
return flattened;
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryKey(categoryId: string, typeId: string) {
|
||||||
|
// Ozon category/type IDs are scalar identifiers; a printable separator is
|
||||||
|
// intentional because PostgreSQL text/jsonb forbids embedded NUL bytes.
|
||||||
|
return `${categoryId}:${typeId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CachedAttribute = {
|
||||||
|
platform_attribute_id: string;
|
||||||
|
complex_id: string;
|
||||||
|
name: string;
|
||||||
|
value_type: 'text' | 'integer' | 'decimal' | 'boolean' | 'dictionary';
|
||||||
|
is_required: boolean;
|
||||||
|
is_collection: boolean;
|
||||||
|
max_value_count: number;
|
||||||
|
dictionary_id: string | null;
|
||||||
|
is_aspect: boolean;
|
||||||
|
source_payload: OzonCategoryAttribute;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function persistAttributes(
|
||||||
|
database: Database,
|
||||||
|
platformId: string,
|
||||||
|
categoryId: string,
|
||||||
|
attributes: OzonCategoryAttribute[],
|
||||||
|
) {
|
||||||
|
const records = attributes
|
||||||
|
.map(toCachedAttribute)
|
||||||
|
.filter((attribute): attribute is CachedAttribute => attribute !== null);
|
||||||
|
await database.transaction(async (tx) => {
|
||||||
|
await tx.query(
|
||||||
|
`DELETE FROM atlas.platform_product_category_attributes
|
||||||
|
WHERE platform_id = $1 AND platform_product_category_id = $2`,
|
||||||
|
[platformId, categoryId],
|
||||||
|
);
|
||||||
|
if (!records.length) return;
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.platform_product_category_attributes
|
||||||
|
(platform_id, platform_product_category_id, platform_attribute_id, complex_id,
|
||||||
|
name, value_type, is_required, is_collection, max_value_count,
|
||||||
|
dictionary_id, is_aspect, source_payload, synced_at)
|
||||||
|
SELECT $1, $2, platform_attribute_id, complex_id, name, value_type,
|
||||||
|
is_required, is_collection, max_value_count, dictionary_id,
|
||||||
|
is_aspect, source_payload, now()
|
||||||
|
FROM jsonb_to_recordset($3::jsonb) AS value(
|
||||||
|
platform_attribute_id text, complex_id text, name text, value_type text,
|
||||||
|
is_required boolean, is_collection boolean, max_value_count integer,
|
||||||
|
dictionary_id text, is_aspect boolean, source_payload jsonb
|
||||||
|
)`,
|
||||||
|
[platformId, categoryId, JSON.stringify(records)],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return { attribute_count: records.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCachedAttribute(value: OzonCategoryAttribute): CachedAttribute | null {
|
||||||
|
const id = String(value.id ?? '').trim();
|
||||||
|
const name = value.name?.trim();
|
||||||
|
if (!id || !name) return null;
|
||||||
|
const dictionaryId = String(value.dictionary_id ?? '').trim();
|
||||||
|
const hasDictionary = !!dictionaryId && dictionaryId !== '0';
|
||||||
|
const isCollection = value.is_collection === true;
|
||||||
|
const requestedMax = Number(value.max_value_count ?? (isCollection ? 100 : 1));
|
||||||
|
return {
|
||||||
|
platform_attribute_id: id,
|
||||||
|
complex_id: String(value.complex_id ?? 0),
|
||||||
|
name,
|
||||||
|
value_type: hasDictionary ? 'dictionary' : mapAttributeType(value.type),
|
||||||
|
is_required: value.is_required === true,
|
||||||
|
is_collection: isCollection,
|
||||||
|
max_value_count: isCollection
|
||||||
|
? Math.max(1, Math.min(100, Number.isFinite(requestedMax) ? requestedMax : 100))
|
||||||
|
: 1,
|
||||||
|
dictionary_id: hasDictionary ? dictionaryId : null,
|
||||||
|
is_aspect: value.is_aspect === true,
|
||||||
|
source_payload: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapAttributeType(value: string | null | undefined): CachedAttribute['value_type'] {
|
||||||
|
switch (value?.toLowerCase()) {
|
||||||
|
case 'integer':
|
||||||
|
return 'integer';
|
||||||
|
case 'decimal':
|
||||||
|
case 'number':
|
||||||
|
return 'decimal';
|
||||||
|
case 'boolean':
|
||||||
|
return 'boolean';
|
||||||
|
default:
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistDictionaryValues(
|
||||||
|
database: Database,
|
||||||
|
platformId: string,
|
||||||
|
dictionaryId: string,
|
||||||
|
values: OzonDictionaryValue[],
|
||||||
|
) {
|
||||||
|
const records = values
|
||||||
|
.map((value) => ({
|
||||||
|
external_value_id: String(value.id ?? '').trim(),
|
||||||
|
value: value.value?.trim(),
|
||||||
|
source_payload: value,
|
||||||
|
}))
|
||||||
|
.filter((value): value is { external_value_id: string; value: string; source_payload: OzonDictionaryValue } =>
|
||||||
|
!!value.external_value_id && !!value.value,
|
||||||
|
);
|
||||||
|
if (!records.length) return { dictionary_value_count: 0 };
|
||||||
|
await database.query(
|
||||||
|
`INSERT INTO atlas.platform_attribute_dictionary_values
|
||||||
|
(platform_id, dictionary_id, external_value_id, value, is_active, source_payload, synced_at)
|
||||||
|
SELECT $1, $2, external_value_id, value, true, source_payload, now()
|
||||||
|
FROM jsonb_to_recordset($3::jsonb) AS value(
|
||||||
|
external_value_id text, value text, source_payload jsonb
|
||||||
|
)
|
||||||
|
ON CONFLICT (platform_id, dictionary_id, external_value_id) DO UPDATE
|
||||||
|
SET value = EXCLUDED.value, is_active = true, source_payload = EXCLUDED.source_payload,
|
||||||
|
synced_at = EXCLUDED.synced_at, updated_at = now()`,
|
||||||
|
[platformId, dictionaryId, JSON.stringify(records)],
|
||||||
|
);
|
||||||
|
return { dictionary_value_count: records.length };
|
||||||
|
}
|
||||||
@@ -12,6 +12,34 @@ export class OzonRequestError extends Error {
|
|||||||
}
|
}
|
||||||
export class OzonRetryableError extends Error {}
|
export class OzonRetryableError extends Error {}
|
||||||
|
|
||||||
|
export type OzonCategoryNode = {
|
||||||
|
description_category_id: number | string;
|
||||||
|
category_name: string;
|
||||||
|
type_id?: number | string | null;
|
||||||
|
type_name?: string | null;
|
||||||
|
disabled?: boolean;
|
||||||
|
children?: OzonCategoryNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OzonCategoryAttribute = {
|
||||||
|
id: number | string;
|
||||||
|
name: string;
|
||||||
|
type?: string | null;
|
||||||
|
is_collection?: boolean;
|
||||||
|
is_required?: boolean;
|
||||||
|
is_aspect?: boolean;
|
||||||
|
max_value_count?: number | null;
|
||||||
|
dictionary_id?: number | string | null;
|
||||||
|
complex_id?: number | string | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OzonDictionaryValue = {
|
||||||
|
id: number | string;
|
||||||
|
value: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uses the category-tree read endpoint as the credential probe. It has no write side effect and,
|
* Uses the category-tree read endpoint as the credential probe. It has no write side effect and,
|
||||||
* unlike warehouse-list, is verified against the current Ozon account integration.
|
* unlike warehouse-list, is verified against the current Ozon account integration.
|
||||||
@@ -20,22 +48,80 @@ export async function verifyOzonCredentials(
|
|||||||
clientId: string,
|
clientId: string,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
await requestOzon(clientId, apiKey, '/v1/description-category/tree', {
|
||||||
|
language: 'ZH_HANS',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 平台目录同步只在 Worker 中使用,密钥仅在此函数调用期间留在内存。 */
|
||||||
|
export async function fetchOzonCategoryTree(clientId: string, apiKey: string) {
|
||||||
|
const body = await requestOzon(clientId, apiKey, '/v1/description-category/tree', {
|
||||||
|
language: 'ZH_HANS',
|
||||||
|
});
|
||||||
|
return readResultArray<OzonCategoryNode>(body, '类目树');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchOzonCategoryAttributes(
|
||||||
|
clientId: string,
|
||||||
|
apiKey: string,
|
||||||
|
categoryId: string,
|
||||||
|
typeId: string,
|
||||||
|
) {
|
||||||
|
const body = await requestOzon(clientId, apiKey, '/v1/description-category/attribute', {
|
||||||
|
description_category_id: numberOrText(categoryId),
|
||||||
|
type_id: numberOrText(typeId),
|
||||||
|
language: 'ZH_HANS',
|
||||||
|
});
|
||||||
|
return readResultArray<OzonCategoryAttribute>(body, '类目属性');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchOzonDictionaryValues(
|
||||||
|
clientId: string,
|
||||||
|
apiKey: string,
|
||||||
|
input: {
|
||||||
|
attributeId: string;
|
||||||
|
categoryId: string;
|
||||||
|
typeId: string;
|
||||||
|
query?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const query = input.query?.trim() ?? '';
|
||||||
|
const path = query
|
||||||
|
? '/v1/description-category/attribute/values/search'
|
||||||
|
: '/v1/description-category/attribute/values';
|
||||||
|
const body = await requestOzon(clientId, apiKey, path, {
|
||||||
|
attribute_id: numberOrText(input.attributeId),
|
||||||
|
description_category_id: numberOrText(input.categoryId),
|
||||||
|
type_id: numberOrText(input.typeId),
|
||||||
|
language: 'ZH_HANS',
|
||||||
|
limit: query ? 100 : 1_000,
|
||||||
|
...(query ? { value: query } : { last_value_id: 0 }),
|
||||||
|
});
|
||||||
|
return readResultArray<OzonDictionaryValue>(body, '属性字典');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestOzon(
|
||||||
|
clientId: string,
|
||||||
|
apiKey: string,
|
||||||
|
path: string,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Promise<unknown> {
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetch('https://api-seller.ozon.ru/v1/description-category/tree', {
|
response = await fetch(`https://api-seller.ozon.ru${path}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Client-Id': clientId,
|
'Client-Id': clientId,
|
||||||
'Api-Key': apiKey,
|
'Api-Key': apiKey,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ language: 'ZH_HANS' }),
|
body: JSON.stringify(body),
|
||||||
signal: AbortSignal.timeout(30_000),
|
signal: AbortSignal.timeout(30_000),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
throw new OzonRetryableError('Ozon request could not be completed');
|
throw new OzonRetryableError('Ozon request could not be completed');
|
||||||
}
|
}
|
||||||
if (response.ok) return;
|
if (response.ok) return response.json() as Promise<unknown>;
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
throw new OzonCredentialError(response.status);
|
throw new OzonCredentialError(response.status);
|
||||||
}
|
}
|
||||||
@@ -44,3 +130,18 @@ export async function verifyOzonCredentials(
|
|||||||
}
|
}
|
||||||
throw new OzonRequestError(response.status);
|
throw new OzonRequestError(response.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readResultArray<T>(body: unknown, resource: string): T[] {
|
||||||
|
if (
|
||||||
|
!body ||
|
||||||
|
typeof body !== 'object' ||
|
||||||
|
!Array.isArray((body as { result?: unknown }).result)
|
||||||
|
) {
|
||||||
|
throw new OzonRetryableError(`Ozon ${resource} 响应格式异常`);
|
||||||
|
}
|
||||||
|
return (body as { result: T[] }).result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberOrText(value: string) {
|
||||||
|
return /^\d+$/.test(value) ? Number(value) : value;
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ API 启动后会在 `/api-docs` 提供 Swagger UI,并在其 OpenAPI 文档中
|
|||||||
| [`watermarks-and-assets.md`](./watermarks-and-assets.md) | 部分已实现 | 水印模板、字体、店铺推荐水印,以及水印图片直传、校验和短时预览;图片渲染派生待实现。 |
|
| [`watermarks-and-assets.md`](./watermarks-and-assets.md) | 部分已实现 | 水印模板、字体、店铺推荐水印,以及水印图片直传、校验和短时预览;图片渲染派生待实现。 |
|
||||||
| [`pricing.md`](./pricing.md) | 已设计 | 汇率、物流价卡、即时试算、定价方案、报价快照衔接。 |
|
| [`pricing.md`](./pricing.md) | 已设计 | 汇率、物流价卡、即时试算、定价方案、报价快照衔接。 |
|
||||||
| [`collection-and-extension.md`](./collection-and-extension.md) | 已设计 | 插件授权、采集写入、采集箱查询、素材转存与状态。 |
|
| [`collection-and-extension.md`](./collection-and-extension.md) | 已设计 | 插件授权、采集写入、采集箱查询、素材转存与状态。 |
|
||||||
| [`product-editor-and-publishing.md`](./product-editor-and-publishing.md) | M3.1 部分已实现 | 编辑上下文、只读快照和产品/平台文案/SKU 的原子草稿保存;类目/属性、媒体、定价、AI 与首次上架仍待实现。 |
|
| [`product-editor-and-publishing.md`](./product-editor-and-publishing.md) | M3.5 部分已实现 | 编辑上下文、完整 SKU 草稿、店铺切换、规格继承、AI 来源素材字段级合并,以及 Ozon 类目树、动态属性和关键词字典缓存同步;媒体、定价、AI 任务与首次上架仍待实现。 |
|
||||||
| [`online-listings.md`](./online-listings.md) | 初版已设计 | 店铺独有的在线商品镜像、读取与异步同步;编辑/认领仍待讨论。 |
|
| [`online-listings.md`](./online-listings.md) | 初版已设计 | 店铺独有的在线商品镜像、读取与异步同步;编辑/认领仍待讨论。 |
|
||||||
|
|
||||||
旧的 [`../product/api.md`](../product/api.md) 是商品域的早期草案:已确认的产品规则可作为背景,但不能当作最终 HTTP 契约。后续商品模块会迁入本目录并替换其中的路径和细节。
|
旧的 [`../product/api.md`](../product/api.md) 是商品域的早期草案:已确认的产品规则可作为背景,但不能当作最终 HTTP 契约。后续商品模块会迁入本目录并替换其中的路径和细节。
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ Headers:扩展 Bearer token 与 `Idempotency-Key` 必填。请求体为已定
|
|||||||
|
|
||||||
请求体不得包含或传入 `workspace_id`、`target_platform_id`、`platform_id`、`store_id`、`api_key`、Cookie、授权头或来源站会话字段。服务端从扩展令牌解析工作空间,并将来源 `platform` 映射为系统已启用的来源平台。
|
请求体不得包含或传入 `workspace_id`、`target_platform_id`、`platform_id`、`store_id`、`api_key`、Cookie、授权头或来源站会话字段。服务端从扩展令牌解析工作空间,并将来源 `platform` 映射为系统已启用的来源平台。
|
||||||
|
|
||||||
服务端在一个事务中:检查工作空间采集箱上限 → 按 `workspace + source_platform + source_item_key` 查找活动记录 → 创建记录,或以本次页面快照覆盖已有记录的来源 URL、标题、价格、原始证据、规范化载荷、SKU 草稿与候选素材。采集阶段的图片和视频只保存为 `pending_selection` 候选记录,**不下载媒体、不投递素材 Worker**;用户在编辑流程中选择媒体后,才调用按需转存接口。重复上报不会增加采集箱配额,且会保留原有 `collection_item` 与 `product` ID;旧快照中尚未完成的候选或任务会被取消,新快照使用新的来源快照版本,旧 Worker 不得回写新快照。
|
服务端在一个事务中:检查工作空间采集箱上限 → 按 `workspace + source_platform + source_item_key` 查找活动记录 → 创建记录,或以本次页面快照覆盖已有记录的来源 URL、标题、价格、原始证据、规范化载荷与候选素材。采集阶段的图片和视频只保存为 `pending_selection` 候选记录,**不下载媒体、不投递素材 Worker**;用户在编辑流程中选择媒体后,才调用按需转存接口。重复上报不会增加采集箱配额,且会保留原有 `collection_item` 与 `product` ID;旧快照中尚未完成的候选或任务会被取消,新快照使用新的来源快照版本,旧 Worker 不得回写新快照。
|
||||||
|
|
||||||
|
尚未创建任何 `product_platform_profile` 时,SKU 草稿仍是来源投影:同一 `source_variant_key` 原位更新,新 key 创建,缺失 key 软删除。**一旦进入任一平台编辑器,SKU 草稿及其平台 mapping 不再是可替换来源投影。** 此后的重复采集仍更新来源证据、规范化载荷和候选素材;同 key 复用原 SKU ID 且不覆盖用户编辑字段,新发现的来源 SKU 会为每个已有平台配置创建 `not_listed` mapping,来源未返回的 SKU 不自动删除或恢复。来源差异的人工审查 UI 另由商品编辑模块提供,采集接口不得通过删除 SKU 或级联删除映射来“覆盖”草稿。
|
||||||
|
|
||||||
成功返回 `201`(新建)或 `200`(复用):
|
成功返回 `201`(新建)或 `200`(复用):
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 商品编辑与首次上架 API
|
# 商品编辑与首次上架 API
|
||||||
|
|
||||||
> M3.0 已实现第 1 节的 `POST /editor-contexts` 和 `GET /editor` 基础读模型,以及对应数据库归属约束。M3.1 已实现第 2 节的产品基础字段、当前平台文案、SKU 与 `offer_id` 的原子草稿保存及双版本并发控制;规格、类目/属性切换、媒体选择、定价、AI 与发布仍是冻结契约,尚未实现。
|
> M3.0 已实现第 1 节的 `POST /editor-contexts` 和 `GET /editor` 基础读模型,以及对应数据库归属约束。M3.1 已实现产品基础字段、当前平台文案、SKU 与 `offer_id` 的原子草稿保存及双版本并发控制。M3.2 已实现目标店铺候选与切换语义、父/子 SKU 的实物/包装规格继承、规格轴、SKU 复制和当前上架状态读模型。M3.3/M3.3.1 已实现 AI 来源素材的字段级合并、原子保存、复采变化提示与 AI 输入读模型。M3.4 已实现本地类目候选、动态属性 schema/字典、属性草稿与类目切换后的未映射保留。M3.5 已通过 Worker 同步 Ozon 类目树,并按类目/关键词按需同步 schema 和字典缓存;媒体选择、定价、AI 任务与发布仍是冻结契约,尚未实现。
|
||||||
|
|
||||||
> 此处的商品始终是采集箱内的 Atlas 草稿。所有平台范围接口要求 `X-Atlas-Platform-Id`。商品通用资料可以跨平台复用;`product_platform_profile`、SKU 首次导入状态和在线商品不能跨平台/门店复用。
|
> 此处的商品始终是采集箱内的 Atlas 草稿。所有平台范围接口要求 `X-Atlas-Platform-Id`。商品通用资料可以跨平台复用;`product_platform_profile`、SKU 首次导入状态和在线商品不能跨平台/门店复用。
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ Headers:`Idempotency-Key` 必填,页头平台由 header 指定,body 为空
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{ "data": {
|
{ "data": {
|
||||||
|
"collection_item_id": "uuid",
|
||||||
"collection_item_id": "uuid", "product_id": "uuid", "profile_id": "uuid",
|
"collection_item_id": "uuid", "product_id": "uuid", "profile_id": "uuid",
|
||||||
"target_store": { "id": "uuid", "name": "Ozon 主店", "currency_code": "RUB" },
|
"target_store": { "id": "uuid", "name": "Ozon 主店", "currency_code": "RUB" },
|
||||||
"editor_path": "/products/uuid/editor"
|
"editor_path": "/products/uuid/editor"
|
||||||
@@ -41,15 +42,37 @@ Headers:`Idempotency-Key` 必填,页头平台由 header 指定,body 为空
|
|||||||
"id": "uuid", "revision": 8, "platform_id": "uuid", "market_code": "RU", "fulfillment": "rfbs",
|
"id": "uuid", "revision": 8, "platform_id": "uuid", "market_code": "RU", "fulfillment": "rfbs",
|
||||||
"target_store": { "id": "uuid", "name": "Ozon 主店", "currency_code": "RUB" },
|
"target_store": { "id": "uuid", "name": "Ozon 主店", "currency_code": "RUB" },
|
||||||
"title": "俄语标题", "description": null, "tags": [],
|
"title": "俄语标题", "description": null, "tags": [],
|
||||||
"category": { "id": "uuid", "platform_type_id": "123", "source": "source_capture" },
|
"category": { "id": "uuid", "platform_type_id": "123", "source": "source_capture", "name": "收纳箱", "path": "家居 › 收纳 › 收纳箱" },
|
||||||
"listing_summary": { "status": "partially_listed", "detail": "1 个 SKU 已导入" }
|
"listing_summary": { "status": "partially_listed", "detail": "1 个 SKU 已导入" }
|
||||||
},
|
},
|
||||||
"variants": [], "attributes": [], "media_candidates": [], "attribute_schema": null,
|
"variants": [], "attributes": [],
|
||||||
|
"source_material": {
|
||||||
|
"title": "用户整理后的来源标题", "description": "用户整理后的来源描述",
|
||||||
|
"price": { "amount": "99.90", "currency_code": "CNY" }, "brand": "来源品牌",
|
||||||
|
"attributes": [{ "key": "material", "name": "材质", "value": "棉" }],
|
||||||
|
"categories": [{ "key": "bags", "name": "箱包" }], "source_snapshot_revision": 3
|
||||||
|
},
|
||||||
|
"source_material_state": {
|
||||||
|
"latest": {},
|
||||||
|
"overridden_fields": ["title", "description"],
|
||||||
|
"changed_fields": ["title"],
|
||||||
|
"base_source_snapshot_revision": 2,
|
||||||
|
"has_source_updates": true
|
||||||
|
},
|
||||||
|
"ai_input": {
|
||||||
|
"source_material": {}, "default_physical_spec": {}, "default_package_spec": {},
|
||||||
|
"variants": [{ "variant_id": "uuid", "display_name": "黑色 / M", "specification_values": [],
|
||||||
|
"effective_physical_spec": {}, "effective_package_spec": {} }]
|
||||||
|
},
|
||||||
"warnings": []
|
"warnings": []
|
||||||
} }
|
} }
|
||||||
```
|
```
|
||||||
|
|
||||||
`variants` 每项同时返回通用 SKU、合并后实物/包装规格、当前平台 listing 摘要(含 `offer_id`、价格、首次导入状态、最近脱敏失败原因)及已选媒体。`attribute_schema` 仅在当前类目已确定时返回。候选和已选媒体只含 `asset_id`、尺寸、MIME、来源角色与短时预览 URL;不含 `object_key` 或永久 URL。
|
`variants` 每项同时返回通用 SKU、合并后实物/包装规格、当前平台 listing 摘要(含 `offer_id`、价格、首次导入状态、最近脱敏失败原因)及已选媒体。动态属性 schema 由第 3 节的独立接口按当前类目读取。候选和已选媒体只含 `asset_id`、尺寸、MIME、来源角色与短时预览 URL;不含 `object_key` 或永久 URL。
|
||||||
|
|
||||||
|
`source_material` 是供页面展示和 AI 使用的**有效来源素材**,由当前 `collection_items.normalized_payload` 的最新规范化值与用户明确修改过的顶层字段合并而成。未修改字段随重复采集自动更新;已修改字段继续保留用户值。`source_material_state.latest` 保留本次最新规范化来源,`overridden_fields` 表示用户覆盖字段,`changed_fields` 表示复采后与用户稿不同的覆盖字段,页面可据此提示、逐项恢复或全部采用最新采集值。原始 `normalized_payload` 与 `raw_evidence` 始终作为采集证据保留,不因编辑而改变。
|
||||||
|
|
||||||
|
`ai_input` 是后续 AI 任务的服务端输入视图:由上述有效 `source_material`、父商品默认实物/包装规格,以及每个 SKU 合并继承后的有效规格与规格轴组成。
|
||||||
|
|
||||||
## 2. 原子保存草稿与 SKU 规则
|
## 2. 原子保存草稿与 SKU 规则
|
||||||
|
|
||||||
@@ -59,11 +82,17 @@ Headers:`If-Match: "{product_revision}:{profile_revision}"`、`Idempotency-Key
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"ai_source_material": {
|
||||||
|
"title": "用户整理后的来源标题", "description": "用户整理后的来源描述",
|
||||||
|
"price": { "amount": "99.90", "currency_code": "CNY" }, "brand": "来源品牌",
|
||||||
|
"attributes": [], "categories": [], "source_snapshot_revision": 3
|
||||||
|
},
|
||||||
|
"ai_source_overridden_fields": ["title", "description"],
|
||||||
"common": { "source_title": "商品名称", "brand": "品牌", "model": null, "barcode": null, "purchase_url": "https://…", "supply_note": null,
|
"common": { "source_title": "商品名称", "brand": "品牌", "model": null, "barcode": null, "purchase_url": "https://…", "supply_note": null,
|
||||||
"default_physical_spec": { "length_mm": "100", "width_mm": "50", "height_mm": "20", "net_weight_g": "300", "status": "confirmed" },
|
"default_physical_spec": { "length_mm": "100", "width_mm": "50", "height_mm": "20", "net_weight_g": "300", "status": "confirmed" },
|
||||||
"default_package_spec": { "length_mm": "120", "width_mm": "60", "height_mm": "30", "shipping_weight_g": "400", "status": "confirmed" } },
|
"default_package_spec": { "length_mm": "120", "width_mm": "60", "height_mm": "30", "shipping_weight_g": "400", "status": "confirmed" } },
|
||||||
"profile": { "target_store_id": "uuid", "fulfillment": "rfbs", "title": "Название", "description": "…", "tags": ["tag"],
|
"profile": { "target_store_id": "uuid", "fulfillment": "rfbs", "title": "Название", "description": "…", "tags": ["tag"],
|
||||||
"platform_product_category_id": "uuid", "platform_type_id": "123", "category_source": "manual" },
|
"platform_product_category_id": "uuid", "category_source": "manual" },
|
||||||
"variants": [{
|
"variants": [{
|
||||||
"id": "uuid-or-null", "client_key": "new-row-key", "copy_from_variant_id": null, "display_name": "黑色 / M", "source_sku": null,
|
"id": "uuid-or-null", "client_key": "new-row-key", "copy_from_variant_id": null, "display_name": "黑色 / M", "source_sku": null,
|
||||||
"specification_values": [{ "axis_key": "color", "value_key": "black", "value_name": "黑色" }], "purchase_cost_cny": "80.00",
|
"specification_values": [{ "axis_key": "color", "value_key": "black", "value_name": "黑色" }], "purchase_cost_cny": "80.00",
|
||||||
@@ -78,6 +107,11 @@ Headers:`If-Match: "{product_revision}:{profile_revision}"`、`Idempotency-Key
|
|||||||
|
|
||||||
语义与校验:
|
语义与校验:
|
||||||
|
|
||||||
|
- `ai_source_material` 是客户端当前看到的完整有效素材,`ai_source_overridden_fields` 仅列出用户明确修改的顶层字段。服务端只持久化这些字段的覆盖值,其余字段继续跟随最新采集结果;恢复字段时将其从该数组移除即可。
|
||||||
|
- 来源素材与商品/SKU 草稿在同一事务保存,受 `product.revision` 保护。`ai_source_material.source_snapshot_revision` 必须等于服务端当前采集版本;保存期间发生复采时返回 `409 SOURCE_SNAPSHOT_CHANGED`,客户端应重新读取合并结果并让用户确认,不能用旧页面覆盖新来源。
|
||||||
|
- 保存来源素材不会修改原始采集证据。`ai_source_overridden_fields` 只允许 `title`、`description`、`price`、`brand`、`attributes`、`categories`,且每项按完整顶层值覆盖,不做数组或对象内部的隐式合并。
|
||||||
|
- `profile.platform_product_category_id` 必须是当前平台的活动类目;服务端从类目记录取得 `platform_type_id`,客户端不能伪造类型 ID。`attributes` 是完整属性草稿集合,属性 ID、复杂属性 ID、值类型、单/多值上限和字典值均由当前类目 schema 校验。
|
||||||
|
- 类目切换或平台 schema 移除属性时,原有不再适用的属性不会进入新的发布草稿;服务端将它们写入 `unmapped_attributes` 并在编辑快照中返回。用户删除 SKU 时,其 SKU 属性随 SKU 一起移除,不生成无主未映射属性。
|
||||||
- `variants` 是当前完整未删除 SKU 集合。省略既有 SKU 表示软删除;无论当前 SPU 状态都允许新增、复制、删除。`copy_from_variant_id` 仅用于新行,复制通用 SKU 数据但不复制任何平台导入映射,生成的新 SKU 从 `not_listed` 开始。
|
- `variants` 是当前完整未删除 SKU 集合。省略既有 SKU 表示软删除;无论当前 SPU 状态都允许新增、复制、删除。`copy_from_variant_id` 仅用于新行,复制通用 SKU 数据但不复制任何平台导入映射,生成的新 SKU 从 `not_listed` 开始。
|
||||||
- 删除已导入 SKU 只从 Atlas 草稿及当前平台可发布集合移除,**不删除、不下架、不修改** Ozon 商品或在线镜像;其他平台历史映射保留。删除后当前平台只剩已导入 SKU 时,SPU 汇总自然为“已上架”。
|
- 删除已导入 SKU 只从 Atlas 草稿及当前平台可发布集合移除,**不删除、不下架、不修改** Ozon 商品或在线镜像;其他平台历史映射保留。删除后当前平台只剩已导入 SKU 时,SPU 汇总自然为“已上架”。
|
||||||
- `null` 的 SKU 规格字段表示继承父商品;包装物流规格的 `status` 为 `missing`/`ambiguous` 时,不允许定价确认或首次上架,但仍允许保存草稿。实物规格只影响尺寸图,不用于物流。
|
- `null` 的 SKU 规格字段表示继承父商品;包装物流规格的 `status` 为 `missing`/`ambiguous` 时,不允许定价确认或首次上架,但仍允许保存草稿。实物规格只影响尺寸图,不用于物流。
|
||||||
@@ -96,11 +130,27 @@ Headers:`If-Match`、`Idempotency-Key` 必填。Body:`{ "target_store_id": "
|
|||||||
|
|
||||||
`GET /api/product-platform-profiles/{profile_id}/attribute-schema`
|
`GET /api/product-platform-profiles/{profile_id}/attribute-schema`
|
||||||
|
|
||||||
当前 profile 必须已有类目。返回平台适配后的字段 schema、字典端点、`is_required`、`is_collection`、`max_value_count`、`is_aspect` 和适用 scope。类别未选返回 `422 CATEGORY_REQUIRED`。
|
当前 profile 必须已有类目。返回本地同步的字段 schema:`platform_attribute_id`、`complex_id`、名称、`value_type`、`is_required`、`is_collection`、`max_value_count`、`dictionary_id` 与 `is_aspect`。可带 `?category_id={uuid}` 预览尚未保存的新类目 schema;该类目仍必须属于当前平台且为活动状态。类别未选返回 `422 CATEGORY_REQUIRED`。
|
||||||
|
|
||||||
`GET /api/product-platform-profiles/{profile_id}/attribute-dictionaries/{dictionary_id}?q={keyword}&cursor={opaque}`
|
`GET /api/product-platform-profiles/{profile_id}/attribute-dictionaries/{dictionary_id}?q={keyword}&cursor={opaque}`
|
||||||
|
|
||||||
转发并缓存平台字典值;不让前端直接持有 Ozon 密钥。字典缺失、过期或类目/属性不匹配时返回 `422`。
|
返回本地同步的字典值 `{ items: [{ value_id, value }], next_cursor }`;可同样传 `category_id` 读取尚未保存的新类目。字典必须属于当前类目 schema,否则返回 `422 ATTRIBUTE_DICTIONARY_INVALID`。首期只读取缓存,不让前端直接持有 Ozon 密钥或直接调用 Ozon。
|
||||||
|
|
||||||
|
`GET /api/product-platform-profiles/{profile_id}/category-options?q={keyword}`
|
||||||
|
|
||||||
|
返回当前平台最多 100 个活动类目,包含本地 UUID、平台外部类目/类型 ID、名称与路径。选择类目本身不立即写库,随下一次 `editor-draft` 原子保存。
|
||||||
|
|
||||||
|
`POST /api/product-platform-profiles/{profile_id}/category-syncs`
|
||||||
|
|
||||||
|
请求目标门店的已验证 Seller API 凭据同步 Ozon 类目树,返回 `202` 与 `platform_category_tree_sync` operation。Worker 将树写入全局平台缓存;密钥仅在 Worker 内存中解密,outbox 与响应均不包含密钥。
|
||||||
|
|
||||||
|
`POST /api/product-platform-profiles/{profile_id}/attribute-schema-syncs?category_id={uuid}`
|
||||||
|
|
||||||
|
为已选活动类目创建 `platform_category_attribute_sync` operation。同步以单类目事务替换 schema;下一次编辑草稿保存会依最新 schema 校验并将不匹配旧值转为未映射草稿。
|
||||||
|
|
||||||
|
`POST /api/product-platform-profiles/{profile_id}/attribute-dictionaries/{dictionary_id}/syncs?category_id={uuid}&q={keyword}`
|
||||||
|
|
||||||
|
按关键词(至少两个字符)创建 `platform_attribute_dictionary_sync` operation,Worker 使用 Ozon 字典搜索接口并 upsert 命中的本地值。不会为打开一个字段而全量下载大字典;前端先读本地缓存,缺少结果时由用户明确选择“从 Ozon 搜索”。
|
||||||
|
|
||||||
`GET /api/product-platform-profiles/{profile_id}/validation`
|
`GET /api/product-platform-profiles/{profile_id}/validation`
|
||||||
|
|
||||||
@@ -140,6 +190,8 @@ Headers:`Idempotency-Key` 必填。Body:
|
|||||||
|
|
||||||
所有 AI 接口都只创建任务并返回 `202` + operation/`ai_task_id`,绝不直接改写草稿或发布数据。
|
所有 AI 接口都只创建任务并返回 `202` + operation/`ai_task_id`,绝不直接改写草稿或发布数据。
|
||||||
|
|
||||||
|
创建 AI 任务前,前端必须先保存当前编辑草稿。服务端从已持久化的 `ai_input` 组装输入,不接收客户端自行拼装的规格:文案和类目任务可读取全部 SKU 的规格摘要;套图和尺寸图任务只读取目标 SKU 的 `effective_physical_spec` / `effective_package_spec`。因此 AI 使用的是用户在编辑页保存后的包装规格与商品规格,而不是采集载荷中的尺寸猜测值。
|
||||||
|
|
||||||
| 接口 | body 核心字段 | 成功结果 |
|
| 接口 | body 核心字段 | 成功结果 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `POST /api/product-platform-profiles/{profile_id}/ai/category-suggestions` | 可选 `hint` | 读取采集证据和当前平台类目树,创建类目候选任务。Ozon 来源默认不自动调用,但用户可主动调用。 |
|
| `POST /api/product-platform-profiles/{profile_id}/ai/category-suggestions` | 可选 `hint` | 读取采集证据和当前平台类目树,创建类目候选任务。Ozon 来源默认不自动调用,但用户可主动调用。 |
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
|
|
||||||
上架状态是“当前平台 + SKU”维度:`未上架`、`处理中`、`上架失败`、`已上架`。SPU 在当前平台的状态由未删除 SKU 汇总为 `未上架`、`已部分上架`、`已上架`。用户可勾选任意未上架 SKU 执行首次上架;已上架 SKU 不显示上架选择,也不发送更新请求。编辑已上架 SKU 时,保存按钮旁必须提示“仅保存 Atlas 草稿,不会更新 Ozon 商品”。
|
上架状态是“当前平台 + SKU”维度:`未上架`、`处理中`、`上架失败`、`已上架`。SPU 在当前平台的状态由未删除 SKU 汇总为 `未上架`、`已部分上架`、`已上架`。用户可勾选任意未上架 SKU 执行首次上架;已上架 SKU 不显示上架选择,也不发送更新请求。编辑已上架 SKU 时,保存按钮旁必须提示“仅保存 Atlas 草稿,不会更新 Ozon 商品”。
|
||||||
|
|
||||||
前端根据平台商品类目 schema 渲染属性控件、必填提示、字典选择、多值限制及变体属性。`is_aspect=true` 默认显示在 SKU 区;采购业务确有需要时,用户可把非 aspect 属性(如容量)改为 SKU 属性。类目变更后,失配属性保留为未映射草稿,不能自动进入发布请求。
|
前端根据当前平台、本地同步的商品类目 schema 渲染属性控件、必填提示、字典选择、多值限制及变体属性。类目列表为空时,用户可从编辑页发起一次 Ozon 类目树同步;选中类目但 schema 缺失时,显式同步该类目属性。字典控件先搜索本地缓存,输入至少两个字符仍无结果时,可显式从 Ozon 搜索并缓存命中值。`is_aspect=true` 在编辑器中按 SKU 展示,其余属性按商品展示;保存仍由服务端复核 schema 与字典值。选择类目和属性与商品草稿同一次保存,`platform_type_id` 从服务端类目缓存取得。类目变更或 schema 下线后,失配属性保留为未映射草稿,不能自动进入发布请求。
|
||||||
|
|
||||||
## 3. SKU 卡片和列表
|
## 3. SKU 卡片和列表
|
||||||
|
|
||||||
@@ -47,6 +47,10 @@ SKU 名称只用于展示;采集 SKU 用稳定 `source_variant_key` 关联,
|
|||||||
|
|
||||||
## 5. 媒体与 AI
|
## 5. 媒体与 AI
|
||||||
|
|
||||||
|
AI 输入由两部分组成:一是用户在右侧“采集的商品信息”中整理并保存的来源标题、描述、品牌、价格、规格参数和来源类目;二是用户在左侧商品与 SKU 表单中保存的实物规格、包装规格和规格轴。右侧实际展示“最新规范化采集值 + 用户明确覆盖字段”的有效结果:没有编辑过的字段随复采更新,编辑过的字段保留用户值,并在来源变化时提供对比、逐项恢复和全部采用最新。原始采集证据不因编辑而改变。
|
||||||
|
|
||||||
|
点击任何 AI 动作前须先保存草稿,服务端再从当前 revision 组装输入;客户端不得把未保存的尺寸或自行解析的来源 JSON 直接提交给 AI。父商品规格与 SKU 有效规格均以后端读模型为准,SKU 的空覆盖字段按继承规则读取左侧父商品规格。
|
||||||
|
|
||||||
SKU 图片编辑弹窗左侧是该 SKU 的已选图片(排序/移除),右侧分组展示 AI 生图记录、采集主图、采集 SKU 图和采集详情图。商品级素材可被多个 SKU 选择;明确有稳定来源关联的 SKU 图只展示给对应 SKU。Ozon 首期每个 SKU 最多选择 15 张发布图片,第一张为主图,可拖动排序;平台适配器以自身限制驱动该上限。
|
SKU 图片编辑弹窗左侧是该 SKU 的已选图片(排序/移除),右侧分组展示 AI 生图记录、采集主图、采集 SKU 图和采集详情图。商品级素材可被多个 SKU 选择;明确有稳定来源关联的 SKU 图只展示给对应 SKU。Ozon 首期每个 SKU 最多选择 15 张发布图片,第一张为主图,可拖动排序;平台适配器以自身限制驱动该上限。
|
||||||
|
|
||||||
已选图片悬浮时显示“编辑”菜单,至少包含“添加水印”。点击后在弹窗中选择一个工作空间水印模板,后台创建带水印的派生素材;原图和其他 SKU 的选择不被改写。用户确认后,以派生图替换该 SKU 当前图片位置,并可在同一弹窗撤销为原图。首期不在发布时自动加水印,也不因门店默认水印自动处理图片;后续再支持默认模板的预选、批量应用或自动应用。
|
已选图片悬浮时显示“编辑”菜单,至少包含“添加水印”。点击后在弹窗中选择一个工作空间水印模板,后台创建带水印的派生素材;原图和其他 SKU 的选择不被改写。用户确认后,以派生图替换该 SKU 当前图片位置,并可在同一弹窗撤销为原图。首期不在发布时自动加水印,也不因门店默认水印自动处理图片;后续再支持默认模板的预选、批量应用或自动应用。
|
||||||
|
|||||||
+70
-1
@@ -1,6 +1,6 @@
|
|||||||
# Atlas Seller Kit 交接说明
|
# Atlas Seller Kit 交接说明
|
||||||
|
|
||||||
> 更新:2026-09-07。此文是新 AI 对话、架构讨论与开发工作的首读入口。开始开发前,必须先阅读并遵守 [`development-collaboration-contract.md`](./development-collaboration-contract.md)。
|
> 更新:2026-09-10。此文是新 AI 对话、架构讨论与开发工作的首读入口。开始开发前,必须先阅读并遵守 [`development-collaboration-contract.md`](./development-collaboration-contract.md)。
|
||||||
|
|
||||||
## 1. 项目定位
|
## 1. 项目定位
|
||||||
|
|
||||||
@@ -10,6 +10,16 @@
|
|||||||
|
|
||||||
当前运行时基线:Node 24.20.0、pnpm 11.25.0、PostgreSQL 18.6、Redis 7.4.11、MinIO `RELEASE.2025-09-07T16-13-09Z`;Web 默认端口为 `8100`,API 默认端口为 `8200`,扩展 dev server 默认端口为 `8110`(仅本地开发用),Worker 无 HTTP 端口。完整目录、依赖边界与版本表见 [`architecture/project-structure.md`](../architecture/project-structure.md) 和 [`architecture/technology-and-deployment.md`](../architecture/technology-and-deployment.md)。
|
当前运行时基线:Node 24.20.0、pnpm 11.25.0、PostgreSQL 18.6、Redis 7.4.11、MinIO `RELEASE.2025-09-07T16-13-09Z`;Web 默认端口为 `8100`,API 默认端口为 `8200`,扩展 dev server 默认端口为 `8110`(仅本地开发用),Worker 无 HTTP 端口。完整目录、依赖边界与版本表见 [`architecture/project-structure.md`](../architecture/project-structure.md) 和 [`architecture/technology-and-deployment.md`](../architecture/technology-and-deployment.md)。
|
||||||
|
|
||||||
|
## 0. 交接结论(先看这里)
|
||||||
|
|
||||||
|
当前已经完成的是“采集 → 采集箱 → 商品编辑草稿”的主要数据闭环,以及 M3.0–M3.4 商品领域基础、编辑器核心、AI 来源素材字段级合并、动态属性和缓存同步基础。数据库迁移、工作空间隔离、同商品归属外键、幂等键和 product/profile 双乐观锁已经落地。
|
||||||
|
|
||||||
|
当前没有完成的是“可直接用于发布”的闭环:候选素材选择/物化、定价确认、AI 任务与候选结果、Ozon 首次发布、在线商品同步仍未开发。真实站点回归也尚未由本项目完成;用户已确认此前提出的首次回归项基本满足采集箱和商品编辑需求,但仍建议在最终发布前保留一轮样本验收。
|
||||||
|
|
||||||
|
当前最高优先级是类目行为改造:旧代码仍允许用户手动同步 Ozon 类目,最新决定要求改为应用启动后同步一次并写入数据库,编辑器使用树形 Cascader 直接读取缓存;Ozon 采集到的类目还需要按层级路径自动回填,且不能覆盖用户已经保存的类目。该项目前处于代码改造中,未完成端到端验收,详见 [1.27](#127-当前进行中2026-09-10类目树交互改造尚未验收)。
|
||||||
|
|
||||||
|
接手前请注意:工作区存在未提交的 M3 商品编辑、M3.4/M3.5 缓存、前端样式和迁移改动。不要用 `git reset --hard` 或批量清理未跟踪文件;先阅读 `git status --short`,按文件和迁移编号判断归属。当前中间代码的共享 contracts 类型需先构建,再执行 Web/API/Worker 检查。
|
||||||
|
|
||||||
### 1.1 开发进度(2026-09-02)
|
### 1.1 开发进度(2026-09-02)
|
||||||
|
|
||||||
今日完成三项工作,全部通过 typecheck、生产构建,并经浏览器或启动实测:
|
今日完成三项工作,全部通过 typecheck、生产构建,并经浏览器或启动实测:
|
||||||
@@ -176,6 +186,54 @@
|
|||||||
- Web 的采集箱“编辑上架”和手动创建现会创建编辑上下文后进入 `/products/{id}/editor`。正式页可编辑产品基础资料、当前平台文案及 SKU/货号,遇到版本冲突会重新加载最新快照。
|
- Web 的采集箱“编辑上架”和手动创建现会创建编辑上下文后进入 `/products/{id}/editor`。正式页可编辑产品基础资料、当前平台文案及 SKU/货号,遇到版本冲突会重新加载最新快照。
|
||||||
- 此批刻意不展示未具备平台 schema 或发布校验的数据:类目/属性切换与未映射属性恢复、规格继承、候选媒体物化/选图、定价确认、AI 和首次 Ozon 导入仍待后续纵向任务。
|
- 此批刻意不展示未具备平台 schema 或发布校验的数据:类目/属性切换与未映射属性恢复、规格继承、候选媒体物化/选图、定价确认、AI 和首次 Ozon 导入仍待后续纵向任务。
|
||||||
|
|
||||||
|
### 1.21 开发进度(2026-09-10):复采与编辑草稿 P0 修复
|
||||||
|
|
||||||
|
- 重复采集不再删除 `product_variants`。未进入编辑器前,SKU 仍可作为最新来源快照投影更新;一旦任一 `product_platform_profile` 存在,复采只更新来源证据/规范化载荷/候选素材,并按稳定 `source_variant_key` 复用既有 SKU ID,不覆盖 Atlas 草稿字段或已建平台 listing。
|
||||||
|
- 复采新发现的来源 SKU 会为该商品全部现有平台 profile 建立独立 `not_listed` listing;来源不再返回的 SKU 不会被自动删除或恢复。这样既保留用户编辑和发布审计,也不会以级联删除绕开 SKU 与 profile 的同商品归属约束。
|
||||||
|
- 扩展集成测试已覆盖“进入编辑、保存人工 SKU 字段与 offer 后再次采集”的场景,并确认原映射保留、新 SKU 可编辑。完整 API 集成测试通过。
|
||||||
|
|
||||||
|
### 1.22 开发进度(2026-09-10):M3.2 商品编辑核心
|
||||||
|
|
||||||
|
- 编辑快照与 `PUT /api/products/{id}/editor-draft` 已覆盖父商品实物/包装规格、SKU 覆盖规格、有效继承规格、规格轴值、每个 SKU 当前平台状态、以及该平台全部活动且凭据有效的目标店铺候选。
|
||||||
|
- 切换目标店铺仍通过同一原子草稿保存:仅 `not_listed` / `failed` 等未导入 listing 改为新店铺;`imported` SKU 永远保留其首次导入的店铺映射。规格确认状态为 `confirmed` 时服务端要求相应四项尺寸/重量完整,数值必须大于零。
|
||||||
|
- Web 编辑页新增父/子规格编辑、规格轴值、复制 SKU、已导入 SKU 删除影响提示、目标店铺选择、上架状态摘要,以及路由离页与浏览器关闭前的未保存修改确认。类目/动态属性、素材选择、定价、AI 与首次发布仍未开始。
|
||||||
|
- 商品编辑集成测试覆盖规格保存/继承、复制 SKU、店铺切换与已导入 SKU 映射保留;Web production build 通过。
|
||||||
|
|
||||||
|
### 1.23 开发进度(2026-09-10):M3.3 编辑器数据闭环
|
||||||
|
|
||||||
|
- 新增迁移 `019_product_editor_ai_source_material.sql`,在商品聚合中持久化结构化 `ai_source_material`。未保存时由当前规范化采集载荷初始化;保存后作为用户整理稿保留,后续复采不覆盖。原始 `normalized_payload` / `raw_evidence` 不被编辑器修改。
|
||||||
|
- `GET /api/products/{id}/editor` 直接返回 `collection_item_id`、`source_material` 和服务端组装的 `ai_input`,Web 不再扫描采集箱前 100 条记录寻找来源。`ai_input` 同时包含父商品默认实物/包装规格,以及每个 SKU 合并继承后的有效规格和规格轴。
|
||||||
|
- `PUT /api/products/{id}/editor-draft` 将 AI 来源素材与商品、平台文案、SKU 和规格原子保存,并继续使用 product/profile 双 revision 与幂等键。右侧来源标题和描述的修改现可持久化;未来 AI 动作必须先保存草稿,再从服务端读模型创建任务。
|
||||||
|
- 商品编辑集成测试覆盖来源载荷初始化、用户素材保存、原始证据不变、复采不覆盖整理稿,以及父/SKU 有效规格进入 AI 输入。
|
||||||
|
|
||||||
|
### 1.24 开发进度(2026-09-10):M3.3.1 来源素材字段级合并
|
||||||
|
|
||||||
|
- 新增迁移 `020_product_ai_source_overrides.sql`:将 M3.3 的整份 `ai_source_material` 草稿演进为 `ai_source_material_overrides` 与 `ai_source_material_base_revision`。服务端以最新规范化采集数据为底稿,只叠加用户明确编辑过的顶层字段。
|
||||||
|
- 编辑快照新增 `source_material_state`:同时返回最新来源、覆盖字段、复采后发生分歧的字段与基线版本。右侧“采集的商品信息”展示合并后的有效值;未编辑字段随复采自动更新,已编辑字段保留,并支持逐项恢复或全部采用最新。
|
||||||
|
- 保存请求新增 `ai_source_overridden_fields`。来源版本必须与服务端当前版本一致;保存过程中发生复采返回 `409 SOURCE_SNAPSHOT_CHANGED`,防止旧页面覆盖新证据。原始 `normalized_payload` / `raw_evidence` 仍保持只读。
|
||||||
|
- AI 输入继续由服务端组装:来源部分读取字段级合并后的有效素材;规格部分读取用户在左侧保存的父商品规格、SKU 覆盖值及其继承后的有效值。
|
||||||
|
|
||||||
|
### 1.25 开发进度(2026-09-10):M3.4 类目与动态属性
|
||||||
|
|
||||||
|
- 新增迁移 `021_product_category_attribute_schema.sql`,建立平台级的类目属性 schema 与字典值缓存表。缓存记录属性值类型、必填、多值上限、字典标识及 `is_aspect`;它们不是工作空间草稿,商品属性只能引用当前平台/类目的有效 schema。
|
||||||
|
- 新增编辑器接口:类目候选、类目属性 schema 与属性字典值。类目 schema/字典接口支持传入未保存的 `category_id` 预览,因此用户选择新类目后不必先保存一次才能填写字段。
|
||||||
|
- 编辑器草稿可原子保存 `platform_product_category_id`、类目来源与商品/SKU 属性;服务端派生 `platform_type_id`,复核类型、值数量、字典值与 SKU 归属。类目变更或 schema 下线的旧属性会转入 `product_unmapped_attributes`,不会默默丢失或进入新的发布 payload。
|
||||||
|
- Web 编辑页已提供类目选择、商品/SKU 维度动态字段、字典选择、未映射属性告警;上架店铺/主题标签保持标准 40px 控件高度,父商品包装重量与三项包装尺寸宽度分别为 150px / 120px。
|
||||||
|
|
||||||
|
### 1.26 开发进度(2026-09-10):M3.5 Ozon 类目与属性缓存同步
|
||||||
|
|
||||||
|
- 已实现类目树、类目属性 schema、字典值缓存的数据表、Worker 同步逻辑、Transactional Outbox 和读 API;缓存属于平台级元数据,不属于某个商品草稿。浏览器、operation 响应与 outbox 事件都不含 API Key。
|
||||||
|
- 当前旧实现仍把“同步 Ozon 类目”暴露为商品编辑页操作;这已被最新产品决策否定,不能作为完成状态使用。属性 schema 仍是按类目读取/按需同步的现状,后续需单独确认是否也改为启动预热。
|
||||||
|
- 字典不会全量拉取:字段先读取已有缓存;用户输入至少两个字符且本地无匹配时,现有实现可显式发起 Ozon `/attribute/values/search`,Worker 仅 upsert 返回的命中项。同步失败应保留旧缓存和可读错误状态。
|
||||||
|
|
||||||
|
### 1.27 当前进行中(2026-09-10):类目树交互改造,尚未验收
|
||||||
|
|
||||||
|
- 最新产品要求:应用启动后为 Ozon 执行一次类目树同步并持久化到 `platform_product_categories`;商品编辑页不再出现“同步/刷新类目”按钮,打开“产品类目”时直接从数据库读取并展示树形层级。
|
||||||
|
- 当前代码已开始把类目候选扩展为 `parent_id`、`is_active` 并将前端控件改为 Cascader,但仍需完成启动同步触发、旧手动同步入口清理、树形数据与表单值的最终对接,以及真实页面回归。不要把这部分当作 M3.5 已完成。
|
||||||
|
- Ozon 采集器现在保存 BreadcrumbList/可见面包屑的名称与 URL 证据,但 `common.categories` 尚未保存可直接关联缓存节点的稳定 Ozon 类目 ID。需要在编辑上下文创建/读取时按规范化层级路径匹配缓存树,并且只在 profile 尚无用户选择时回填 `platform_product_category_id`,来源标记为 `source_capture`;用户手工选择或已保存类目不能被复采覆盖。
|
||||||
|
- 类目树同步应使用一个已验证的 Ozon 店铺凭据作为平台共享缓存来源,任务去重、失败重试和并发启动保护必须明确;不能为每个商品或每次打开编辑器重复请求 Ozon。类目缓存为空时页面应显示“缓存准备中/暂无可用类目”,而不是让用户发起同步。
|
||||||
|
- 当前中间代码的 TypeScript 检查需要先构建共享 contracts,再检查 Web/API/Worker;已验证命令为:`tsc -p packages/contracts/tsconfig.json`、随后分别执行各应用 `tsc --noEmit`。启动同步与类目自动回填尚未有集成测试。
|
||||||
|
|
||||||
## 2. 已确认产品决策
|
## 2. 已确认产品决策
|
||||||
|
|
||||||
| 主题 | 决策 |
|
| 主题 | 决策 |
|
||||||
@@ -236,6 +294,17 @@ User → Workspace → Membership / Role
|
|||||||
|
|
||||||
下一步建议按以下顺序推进:
|
下一步建议按以下顺序推进:
|
||||||
|
|
||||||
|
> 注意:下面的历史清单仍保留用于追溯;当前执行优先级以 [`1.27 当前进行中`](#127-当前进行中2026-09-10类目树交互改造尚未验收) 和本段补充为准。先完成类目启动同步、树形选择与 Ozon 来源类目自动回填,再进入真实采集回归和后续发布能力。
|
||||||
|
|
||||||
|
当前执行顺序:
|
||||||
|
|
||||||
|
1. 实现 Worker 启动时一次性、幂等预热 Ozon 类目树;移除编辑器手动同步 UI;Cascader 直接读取数据库树。
|
||||||
|
2. 在创建/读取商品编辑上下文时,用来源面包屑层级与缓存 `path` 精确匹配 Ozon 类目;仅在 profile 没有用户已选类目时回填,并标记 `source_capture`。
|
||||||
|
3. 为启动同步、空缓存、重复启动、失败重试、树形选择和自动回填补 API/Worker 集成测试;重新执行 contracts、API、Worker、Web typecheck、Web build 与真实 8100 页面回归。
|
||||||
|
4. 完成动态属性 schema/字典缓存、候选媒体、定价、AI 任务、首次发布和在线商品同步的剩余纵向闭环。
|
||||||
|
|
||||||
|
### 历史计划(仅供追溯)
|
||||||
|
|
||||||
1. 在真实 Ozon 多 SKU 页面加载已构建扩展,确认 MAIN world bridge 未被页面策略阻断、三 SKU 都写入采集测试页、SKU 级画廊关系正确,并在新版规范化载荷确认完整特征与描述;再以任一兄弟 SKU 页面重采集,确认服务端覆盖同一记录。
|
1. 在真实 Ozon 多 SKU 页面加载已构建扩展,确认 MAIN world bridge 未被页面策略阻断、三 SKU 都写入采集测试页、SKU 级画廊关系正确,并在新版规范化载荷确认完整特征与描述;再以任一兄弟 SKU 页面重采集,确认服务端覆盖同一记录。
|
||||||
2. 用允许保存的单 SKU、单规格轴和颜色×尺码页面样本,在 1688、淘宝、天猫逐项验证采集结果;页面状态未暴露组合数据时补站点适配器,但绝不以规格轴猜测 SKU。之后独立实施 Seller 网页四接口本地观察实验,默认关闭、仅保存净化结果且不影响标准采集。
|
2. 用允许保存的单 SKU、单规格轴和颜色×尺码页面样本,在 1688、淘宝、天猫逐项验证采集结果;页面状态未暴露组合数据时补站点适配器,但绝不以规格轴猜测 SKU。之后独立实施 Seller 网页四接口本地观察实验,默认关闭、仅保存净化结果且不影响标准采集。
|
||||||
3. 将当前 `/collection-test/{collectionItemId}` 只读检查面替换为正式商品编辑入口:按页头平台创建发布配置、预选同平台默认门店,并接入 SKU/媒体/类目编辑。
|
3. 将当前 `/collection-test/{collectionItemId}` 只读检查面替换为正式商品编辑入口:按页头平台创建发布配置、预选同平台默认门店,并接入 SKU/媒体/类目编辑。
|
||||||
|
|||||||
@@ -84,7 +84,9 @@
|
|||||||
| `failed` | 采集请求或关键记录创建失败;保留可诊断错误。 |
|
| `failed` | 采集请求或关键记录创建失败;保留可诊断错误。 |
|
||||||
| `archived` | 用户不再处理的采集记录。 |
|
| `archived` | 用户不再处理的采集记录。 |
|
||||||
|
|
||||||
同一工作空间内,以 `source_platform + source_item_key` 识别同一来源商品。重复“采集商品”或“编辑上架”保留已有采集记录和商品草稿 ID,但以本次页面重新采集的来源信息覆盖上一份来源快照:标题、价格、原始证据、规范化载荷、来源 SKU 与候选素材均以最新采集为准。旧素材任务取消,新素材任务必须带新的快照版本,旧 Worker 不得回写最新内容。用户希望保留旧版本或创建副本的需求,留待正式商品编辑模块提供明确操作后再设计。
|
同一工作空间内,以 `source_platform + source_item_key` 识别同一来源商品。重复“采集商品”或“编辑上架”保留已有采集记录和商品草稿 ID,并以本次页面重新采集的来源信息覆盖上一份**来源快照**:标题、价格、原始证据、规范化载荷与候选素材均以最新采集为准。旧素材任务取消,新素材任务必须带新的快照版本,旧 Worker 不得回写最新内容。
|
||||||
|
|
||||||
|
在尚未创建平台发布配置前,SKU 草稿可随最新来源快照更新;进入任一平台编辑器后,SKU 已可能含用户编辑、货号、价格或发布历史,来源刷新不得覆盖、删除或恢复这些 Atlas 草稿。此时同 `source_variant_key` 只复用既有 SKU ID,新来源 SKU 创建新的未上架平台 mapping,来源不再出现的 SKU 保留待用户明确处理。商品编辑模块后续提供来源差异审查/应用操作;采集端不能用删除或级联删除来伪造“覆盖”。
|
||||||
|
|
||||||
### 3.1 采集箱与目标店铺的绑定时点
|
### 3.1 采集箱与目标店铺的绑定时点
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,77 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export type ProductAiSourceAttribute = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProductAiSourceCategory = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProductAiSourceMaterial = {
|
||||||
|
title: string | null;
|
||||||
|
description: string | null;
|
||||||
|
price: { amount: string; currency_code: string } | null;
|
||||||
|
brand: string | null;
|
||||||
|
attributes: ProductAiSourceAttribute[];
|
||||||
|
categories: ProductAiSourceCategory[];
|
||||||
|
source_snapshot_revision: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProductAiSourceField =
|
||||||
|
'title' | 'description' | 'price' | 'brand' | 'attributes' | 'categories';
|
||||||
|
|
||||||
|
export type ProductAiSourceMaterialState = {
|
||||||
|
latest: ProductAiSourceMaterial;
|
||||||
|
overridden_fields: ProductAiSourceField[];
|
||||||
|
changed_fields: ProductAiSourceField[];
|
||||||
|
base_source_snapshot_revision: number | null;
|
||||||
|
has_source_updates: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProductAiInput = {
|
||||||
|
source_material: ProductAiSourceMaterial;
|
||||||
|
default_physical_spec: PhysicalSpec;
|
||||||
|
default_package_spec: PackageSpec;
|
||||||
|
variants: Array<{
|
||||||
|
variant_id: string;
|
||||||
|
display_name: string | null;
|
||||||
|
specification_values: SpecificationValue[];
|
||||||
|
effective_physical_spec: PhysicalSpec;
|
||||||
|
effective_package_spec: PackageSpec;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProductEditorCategoryOption = {
|
||||||
|
id: string;
|
||||||
|
parent_id: string | null;
|
||||||
|
external_category_id: string;
|
||||||
|
external_type_id: string | null;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
is_active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProductAttributeSchema = {
|
||||||
|
platform_attribute_id: string;
|
||||||
|
complex_id: string;
|
||||||
|
name: string;
|
||||||
|
value_type: 'text' | 'integer' | 'decimal' | 'boolean' | 'dictionary';
|
||||||
|
is_required: boolean;
|
||||||
|
is_collection: boolean;
|
||||||
|
max_value_count: number;
|
||||||
|
dictionary_id: string | null;
|
||||||
|
is_aspect: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProductAttributeDictionaryValue = {
|
||||||
|
value_id: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* M3.0 editor entry and read-model contracts. Draft mutation, pricing, and
|
* M3.0 editor entry and read-model contracts. Draft mutation, pricing, and
|
||||||
* publishing DTOs intentionally stay out of this first editor slice.
|
* publishing DTOs intentionally stay out of this first editor slice.
|
||||||
@@ -13,6 +85,7 @@ export type ProductEditorContext = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ProductEditorSnapshot = {
|
export type ProductEditorSnapshot = {
|
||||||
|
collection_item_id: string;
|
||||||
product: {
|
product: {
|
||||||
id: string;
|
id: string;
|
||||||
revision: number;
|
revision: number;
|
||||||
@@ -24,6 +97,8 @@ export type ProductEditorSnapshot = {
|
|||||||
purchase_url: string | null;
|
purchase_url: string | null;
|
||||||
supply_note: string | null;
|
supply_note: string | null;
|
||||||
};
|
};
|
||||||
|
default_physical_spec: PhysicalSpec;
|
||||||
|
default_package_spec: PackageSpec;
|
||||||
};
|
};
|
||||||
profile: {
|
profile: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -32,6 +107,11 @@ export type ProductEditorSnapshot = {
|
|||||||
market_code: string | null;
|
market_code: string | null;
|
||||||
fulfillment: string;
|
fulfillment: string;
|
||||||
target_store: { id: string; name: string; currency_code: string };
|
target_store: { id: string; name: string; currency_code: string };
|
||||||
|
available_target_stores: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
currency_code: string;
|
||||||
|
}>;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
@@ -39,6 +119,12 @@ export type ProductEditorSnapshot = {
|
|||||||
id: string | null;
|
id: string | null;
|
||||||
platform_type_id: string | null;
|
platform_type_id: string | null;
|
||||||
source: string | null;
|
source: string | null;
|
||||||
|
name: string | null;
|
||||||
|
path: string | null;
|
||||||
|
};
|
||||||
|
listing_summary: {
|
||||||
|
status: 'not_listed' | 'partially_listed' | 'listed';
|
||||||
|
detail: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
variants: Array<{
|
variants: Array<{
|
||||||
@@ -48,6 +134,11 @@ export type ProductEditorSnapshot = {
|
|||||||
display_name: string | null;
|
display_name: string | null;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
purchase_cost_cny: string | null;
|
purchase_cost_cny: string | null;
|
||||||
|
specification_values: SpecificationValue[];
|
||||||
|
physical_spec: VariantPhysicalSpec;
|
||||||
|
package_spec: VariantPackageSpec;
|
||||||
|
effective_physical_spec: PhysicalSpec;
|
||||||
|
effective_package_spec: PackageSpec;
|
||||||
listing: {
|
listing: {
|
||||||
id: string;
|
id: string;
|
||||||
offer_id: string | null;
|
offer_id: string | null;
|
||||||
@@ -80,45 +171,246 @@ export type ProductEditorSnapshot = {
|
|||||||
reason: string;
|
reason: string;
|
||||||
status: string;
|
status: string;
|
||||||
}>;
|
}>;
|
||||||
|
source_material: ProductAiSourceMaterial;
|
||||||
|
source_material_state: ProductAiSourceMaterialState;
|
||||||
|
ai_input: ProductAiInput;
|
||||||
warnings: Array<{ code: string; message: string }>;
|
warnings: Array<{ code: string; message: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SpecStatus = 'missing' | 'ambiguous' | 'confirmed';
|
||||||
|
export type PhysicalSpec = {
|
||||||
|
length_mm: string | null;
|
||||||
|
width_mm: string | null;
|
||||||
|
height_mm: string | null;
|
||||||
|
net_weight_g: string | null;
|
||||||
|
status: SpecStatus;
|
||||||
|
};
|
||||||
|
export type PackageSpec = {
|
||||||
|
length_mm: string | null;
|
||||||
|
width_mm: string | null;
|
||||||
|
height_mm: string | null;
|
||||||
|
shipping_weight_g: string | null;
|
||||||
|
status: SpecStatus;
|
||||||
|
};
|
||||||
|
export type VariantPhysicalSpec = Omit<PhysicalSpec, 'status'> & {
|
||||||
|
status: SpecStatus | null;
|
||||||
|
};
|
||||||
|
export type VariantPackageSpec = Omit<PackageSpec, 'status'> & {
|
||||||
|
status: SpecStatus | null;
|
||||||
|
};
|
||||||
|
export type SpecificationValue = {
|
||||||
|
axis_key: string;
|
||||||
|
value_key: string;
|
||||||
|
value_name: string;
|
||||||
|
};
|
||||||
|
|
||||||
const uuid = z.uuid();
|
const uuid = z.uuid();
|
||||||
const nullableText = (max: number) => z.string().trim().max(max).nullable();
|
const nullableText = (max: number) => z.string().trim().max(max).nullable();
|
||||||
|
const nullableDecimal = z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d+(\.\d{1,3})?$/)
|
||||||
|
.refine((value) => Number(value) > 0, '必须大于 0')
|
||||||
|
.nullable();
|
||||||
|
const specStatus = z.enum(['missing', 'ambiguous', 'confirmed']);
|
||||||
|
const physicalSpec = z
|
||||||
|
.object({
|
||||||
|
length_mm: nullableDecimal,
|
||||||
|
width_mm: nullableDecimal,
|
||||||
|
height_mm: nullableDecimal,
|
||||||
|
net_weight_g: nullableDecimal,
|
||||||
|
status: specStatus,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
const packageSpec = z
|
||||||
|
.object({
|
||||||
|
length_mm: nullableDecimal,
|
||||||
|
width_mm: nullableDecimal,
|
||||||
|
height_mm: nullableDecimal,
|
||||||
|
shipping_weight_g: nullableDecimal,
|
||||||
|
status: specStatus,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
const variantPhysicalSpec = physicalSpec
|
||||||
|
.extend({ status: specStatus.nullable() })
|
||||||
|
.strict();
|
||||||
|
const variantPackageSpec = packageSpec
|
||||||
|
.extend({ status: specStatus.nullable() })
|
||||||
|
.strict();
|
||||||
|
const specificationValue = z
|
||||||
|
.object({
|
||||||
|
axis_key: z.string().trim().min(1).max(100),
|
||||||
|
value_key: z.string().trim().min(1).max(200),
|
||||||
|
value_name: z.string().trim().min(1).max(300),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const productAttributeDraft = z
|
||||||
|
.object({
|
||||||
|
scope: z.enum(['common', 'variant']),
|
||||||
|
variant_id: uuid.nullable(),
|
||||||
|
platform_attribute_id: z.string().trim().min(1).max(200),
|
||||||
|
complex_id: z.string().trim().min(1).max(100),
|
||||||
|
values: z.array(z.string().trim().min(1).max(2_000)).min(1).max(100),
|
||||||
|
language_code: z.string().trim().min(2).max(20).nullable(),
|
||||||
|
source: z
|
||||||
|
.enum(['manual', 'source_capture', 'ai_suggestion'])
|
||||||
|
.default('manual'),
|
||||||
|
confirmation_status: z
|
||||||
|
.enum(['missing', 'ambiguous', 'confirmed'])
|
||||||
|
.default('confirmed'),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.superRefine((attribute, context) => {
|
||||||
|
if (attribute.scope === 'common' && attribute.variant_id !== null) {
|
||||||
|
context.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['variant_id'],
|
||||||
|
message: '通用属性不能关联 SKU',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (attribute.scope === 'variant' && attribute.variant_id === null) {
|
||||||
|
context.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['variant_id'],
|
||||||
|
message: 'SKU 属性必须关联 SKU',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const productAiSourceMaterialSchema = z
|
||||||
|
.object({
|
||||||
|
title: nullableText(1_000),
|
||||||
|
description: nullableText(100_000),
|
||||||
|
price: z
|
||||||
|
.object({
|
||||||
|
amount: z.string().regex(/^\d+(\.\d{1,4})?$/),
|
||||||
|
currency_code: z.string().length(3).toUpperCase(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.nullable(),
|
||||||
|
brand: nullableText(300),
|
||||||
|
attributes: z
|
||||||
|
.array(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
key: z.string().trim().min(1).max(200),
|
||||||
|
name: z.string().trim().min(1).max(300),
|
||||||
|
value: z.string().max(2_000),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
)
|
||||||
|
.max(500),
|
||||||
|
categories: z
|
||||||
|
.array(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
key: z.string().trim().min(1).max(300),
|
||||||
|
name: z.string().trim().min(1).max(500),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
)
|
||||||
|
.max(50),
|
||||||
|
source_snapshot_revision: z.number().int().positive(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const productAiSourceFieldSchema = z.enum([
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'price',
|
||||||
|
'brand',
|
||||||
|
'attributes',
|
||||||
|
'categories',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const productAiSourceMaterialOverridesSchema =
|
||||||
|
productAiSourceMaterialSchema
|
||||||
|
.omit({ source_snapshot_revision: true })
|
||||||
|
.partial()
|
||||||
|
.strict();
|
||||||
|
|
||||||
/** M3.1 intentionally covers editable product/profile/SKU fundamentals. */
|
/** M3.1 intentionally covers editable product/profile/SKU fundamentals. */
|
||||||
export const saveProductEditorDraftRequestSchema = z.object({
|
export const saveProductEditorDraftRequestSchema = z
|
||||||
common: z.object({
|
.object({
|
||||||
|
ai_source_material: productAiSourceMaterialSchema,
|
||||||
|
ai_source_overridden_fields: z
|
||||||
|
.array(productAiSourceFieldSchema)
|
||||||
|
.max(6)
|
||||||
|
.refine(
|
||||||
|
(fields) => new Set(fields).size === fields.length,
|
||||||
|
'覆盖字段不能重复',
|
||||||
|
),
|
||||||
|
common: z
|
||||||
|
.object({
|
||||||
source_title: nullableText(1_000),
|
source_title: nullableText(1_000),
|
||||||
brand: nullableText(300),
|
brand: nullableText(300),
|
||||||
model: nullableText(300),
|
model: nullableText(300),
|
||||||
barcode: nullableText(300),
|
barcode: nullableText(300),
|
||||||
purchase_url: z.url().nullable(),
|
purchase_url: z.url().nullable(),
|
||||||
supply_note: nullableText(4_000),
|
supply_note: nullableText(4_000),
|
||||||
}).strict(),
|
default_physical_spec: physicalSpec,
|
||||||
profile: z.object({
|
default_package_spec: packageSpec,
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
profile: z
|
||||||
|
.object({
|
||||||
target_store_id: uuid,
|
target_store_id: uuid,
|
||||||
fulfillment: z.enum(['rfbs', 'fbs', 'fbo', 'fbp', 'unknown']),
|
fulfillment: z.enum(['rfbs', 'fbs', 'fbo', 'fbp', 'unknown']),
|
||||||
title: nullableText(1_000),
|
title: nullableText(1_000),
|
||||||
description: nullableText(100_000),
|
description: nullableText(100_000),
|
||||||
tags: z.array(z.string().trim().min(1).max(100)).max(30),
|
tags: z.array(z.string().trim().min(1).max(100)).max(30),
|
||||||
}).strict(),
|
platform_product_category_id: uuid.nullable().optional(),
|
||||||
variants: z.array(z.object({
|
category_source: z
|
||||||
|
.enum(['source_capture', 'ai_suggestion', 'manual'])
|
||||||
|
.nullable()
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
attributes: z.array(productAttributeDraft).max(1_000).optional(),
|
||||||
|
variants: z
|
||||||
|
.array(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
id: uuid.nullable(),
|
id: uuid.nullable(),
|
||||||
client_key: z.string().trim().min(1).max(100).nullable(),
|
client_key: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.nullable(),
|
||||||
copy_from_variant_id: uuid.nullable().optional(),
|
copy_from_variant_id: uuid.nullable().optional(),
|
||||||
display_name: nullableText(1_000),
|
display_name: nullableText(1_000),
|
||||||
source_sku: nullableText(500),
|
source_sku: nullableText(500),
|
||||||
purchase_cost_cny: z.string().regex(/^\d+(\.\d{1,4})?$/).nullable(),
|
specification_values: z
|
||||||
|
.array(specificationValue)
|
||||||
|
.max(20),
|
||||||
|
purchase_cost_cny: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d+(\.\d{1,4})?$/)
|
||||||
|
.nullable(),
|
||||||
|
physical_spec: variantPhysicalSpec,
|
||||||
|
package_spec: variantPackageSpec,
|
||||||
offer_id: nullableText(500),
|
offer_id: nullableText(500),
|
||||||
}).strict().superRefine((variant, context) => {
|
})
|
||||||
|
.strict()
|
||||||
|
.superRefine((variant, context) => {
|
||||||
if (!variant.id && !variant.client_key) {
|
if (!variant.id && !variant.client_key) {
|
||||||
context.addIssue({ code: 'custom', path: ['client_key'], message: '新 SKU 必须提供 client_key' });
|
context.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['client_key'],
|
||||||
|
message: '新 SKU 必须提供 client_key',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
})).min(1).max(100),
|
}),
|
||||||
}).strict();
|
)
|
||||||
|
.min(1)
|
||||||
|
.max(100),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
export type SaveProductEditorDraftRequest = z.infer<typeof saveProductEditorDraftRequestSchema>;
|
export type SaveProductEditorDraftRequest = z.infer<
|
||||||
|
typeof saveProductEditorDraftRequestSchema
|
||||||
|
>;
|
||||||
export type SaveProductEditorDraftResponse = {
|
export type SaveProductEditorDraftResponse = {
|
||||||
product_revision: number;
|
product_revision: number;
|
||||||
profile_revision: number;
|
profile_revision: number;
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
SET search_path TO atlas, public;
|
||||||
|
|
||||||
|
-- M3.3 keeps the user-curated AI source material beside the product aggregate.
|
||||||
|
-- The original collection normalized_payload/raw_evidence remains immutable
|
||||||
|
-- evidence; this nullable document is initialized from that evidence on read
|
||||||
|
-- and becomes durable only after the user saves the editor draft.
|
||||||
|
ALTER TABLE products
|
||||||
|
ADD COLUMN ai_source_material JSONB,
|
||||||
|
ADD CONSTRAINT ck_products_ai_source_material_object
|
||||||
|
CHECK (ai_source_material IS NULL OR jsonb_typeof(ai_source_material) = 'object');
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
SET search_path TO atlas, public;
|
||||||
|
|
||||||
|
-- M3.3.1 evolves the full AI source snapshot into field-level overrides.
|
||||||
|
-- Preserve any document saved between migrations 019 and 020 by treating all
|
||||||
|
-- of its material fields as explicit overrides based on its recorded source
|
||||||
|
-- snapshot revision.
|
||||||
|
ALTER TABLE products
|
||||||
|
ADD COLUMN ai_source_material_base_revision BIGINT
|
||||||
|
CHECK (ai_source_material_base_revision IS NULL OR ai_source_material_base_revision > 0);
|
||||||
|
|
||||||
|
-- The migrator owns the table, but products uses FORCE RLS. Temporarily turn
|
||||||
|
-- it off inside this atomic migration so existing rows from every workspace
|
||||||
|
-- can be transformed; the table lock prevents application writes in between.
|
||||||
|
ALTER TABLE products NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE products DISABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
UPDATE products
|
||||||
|
SET ai_source_material_base_revision =
|
||||||
|
CASE
|
||||||
|
WHEN ai_source_material ? 'source_snapshot_revision'
|
||||||
|
AND (ai_source_material->>'source_snapshot_revision') ~ '^[1-9][0-9]*$'
|
||||||
|
THEN (ai_source_material->>'source_snapshot_revision')::BIGINT
|
||||||
|
ELSE NULL
|
||||||
|
END,
|
||||||
|
ai_source_material = ai_source_material - 'source_snapshot_revision'
|
||||||
|
WHERE ai_source_material IS NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE products RENAME COLUMN ai_source_material TO ai_source_material_overrides;
|
||||||
|
ALTER TABLE products
|
||||||
|
ALTER COLUMN ai_source_material_overrides SET DEFAULT '{}'::jsonb;
|
||||||
|
|
||||||
|
UPDATE products
|
||||||
|
SET ai_source_material_overrides = '{}'::jsonb
|
||||||
|
WHERE ai_source_material_overrides IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE products
|
||||||
|
ALTER COLUMN ai_source_material_overrides SET NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE products ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE products FORCE ROW LEVEL SECURITY;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
SET search_path TO atlas, public;
|
||||||
|
|
||||||
|
-- M3.4 stores the platform's cached category attribute contract separately
|
||||||
|
-- from workspace drafts. Product attributes always reference IDs from this
|
||||||
|
-- schema; dictionary values are a cache, never free-form client metadata.
|
||||||
|
CREATE TABLE platform_product_category_attributes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE CASCADE,
|
||||||
|
platform_product_category_id UUID NOT NULL,
|
||||||
|
platform_attribute_id TEXT NOT NULL,
|
||||||
|
complex_id TEXT NOT NULL DEFAULT '0',
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
value_type TEXT NOT NULL CHECK (value_type IN ('text', 'integer', 'decimal', 'boolean', 'dictionary')),
|
||||||
|
is_required BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
is_collection BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
max_value_count INTEGER NOT NULL DEFAULT 1 CHECK (max_value_count BETWEEN 1 AND 100),
|
||||||
|
dictionary_id TEXT,
|
||||||
|
is_aspect BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
source_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
synced_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
FOREIGN KEY (platform_id, platform_product_category_id)
|
||||||
|
REFERENCES platform_product_categories(platform_id, id) ON DELETE CASCADE,
|
||||||
|
UNIQUE (platform_id, platform_product_category_id, platform_attribute_id, complex_id),
|
||||||
|
CHECK ((value_type = 'dictionary') = (dictionary_id IS NOT NULL)),
|
||||||
|
CHECK (is_collection OR max_value_count = 1)
|
||||||
|
);
|
||||||
|
CREATE INDEX ix_platform_product_category_attributes_lookup
|
||||||
|
ON platform_product_category_attributes (platform_id, platform_product_category_id, is_required DESC, name, id);
|
||||||
|
|
||||||
|
CREATE TABLE platform_attribute_dictionary_values (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE CASCADE,
|
||||||
|
dictionary_id TEXT NOT NULL,
|
||||||
|
external_value_id TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
source_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
synced_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (platform_id, dictionary_id, external_value_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX ix_platform_attribute_dictionary_values_search
|
||||||
|
ON platform_attribute_dictionary_values (platform_id, dictionary_id, is_active, value, external_value_id);
|
||||||
Reference in New Issue
Block a user