diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 1363aa0..530a0e7 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -11,6 +11,7 @@ import { WatermarksModule } from './watermarks/watermarks.module.js'; import { MediaModule } from './media/media.module.js'; import { CollectionModule } from './collection/collection.module.js'; import { ExtensionModule } from './extension/extension.module.js'; +import { ProductEditorModule } from './product-editor/product-editor.module.js'; @Controller() class HealthController { @@ -49,6 +50,7 @@ class HealthController { MediaModule, CollectionModule, ExtensionModule, + ProductEditorModule, ], controllers: [HealthController], }) diff --git a/apps/api/src/collection/collection.service.ts b/apps/api/src/collection/collection.service.ts index f444438..90ab900 100644 --- a/apps/api/src/collection/collection.service.ts +++ b/apps/api/src/collection/collection.service.ts @@ -4,6 +4,7 @@ import type { CreateCollectionItemRequest, ListCollectionItemsQuery, Materialize import type { Database, DatabaseTransaction } from '@atlas/database'; import { ApiError } from '../common/api-error.js'; import { IdempotencyService } from '../common/idempotency.service.js'; +import { throwRevisionConflict } from '../common/revision.js'; import { DATABASE } from '../database/database.module.js'; type Row = { id: string; product_id: string; source_kind: 'platform' | 'manual'; platform_code: string | null; source_url: string | null; source_title: string | null; source_min_price: string | null; source_max_price: string | null; source_currency_code: string | null; note: string | null; status: string; created_at: Date | string; updated_at: Date | string; revision: number; variant_count: string; transfer_status: string | null }; @@ -37,14 +38,14 @@ export class CollectionService { const item = await this.requireItem(tx, id); const source = await tx.query<{ normalized_payload: unknown; raw_evidence: unknown; parser_version: string | null; collected_at: Date | string | null; source_snapshot_revision: string | number }>('SELECT normalized_payload, raw_evidence, parser_version, collected_at, source_snapshot_revision FROM atlas.collection_items WHERE id = $1 AND deleted_at IS NULL', [id]); const variants = await tx.query<{ id: string; source_variant_key: string | null; source_sku: string | null; display_name: string | null; sort_order: number }>('SELECT id, source_variant_key, source_sku, display_name, sort_order FROM atlas.product_variants WHERE product_id = $1 AND deleted_at IS NULL ORDER BY sort_order, id', [item.product_id]); - const transfers = await tx.query<{ id: string; source_variant_id: string | null; media_type: 'image' | 'video'; poster_url: string | null; role: string; source_url: string; status: string; asset_id: string | null; error_payload: unknown; created_at: Date | string; completed_at: Date | string | null }>('SELECT id, source_variant_id, media_type::text AS media_type, poster_url, role::text AS role, source_url, status::text AS status, asset_id, error_payload, created_at, completed_at FROM atlas.collection_asset_transfers WHERE collection_item_id = $1 AND source_snapshot_revision = $2 ORDER BY created_at, id', [id, source.rows[0]?.source_snapshot_revision ?? 1]); + const transfers = await tx.query<{ id: string; source_variant_id: string | null; media_type: 'image' | 'video'; poster_url: string | null; role: string; source_url: string; status: string; asset_id: string | null; error_payload: unknown; created_at: Date | string; completed_at: Date | string | null }>(`SELECT id, source_variant_id, media_type::text AS media_type, poster_url, role::text AS role, source_url, status::text AS status, asset_id, error_payload, created_at, completed_at FROM atlas.collection_asset_transfers WHERE collection_item_id = $1 AND source_snapshot_revision = $2 ORDER BY coalesce((error_payload->>'sort_order')::integer, 0), created_at, id`, [id, source.rows[0]?.source_snapshot_revision ?? 1]); return { ...item, normalized_payload: source.rows[0]?.normalized_payload ?? {}, raw_evidence: source.rows[0]?.raw_evidence ?? {}, parser_version: source.rows[0]?.parser_version ?? null, collected_at: source.rows[0]?.collected_at ? iso(source.rows[0].collected_at) : null, variants: variants.rows, asset_transfers: transfers.rows.map((transfer) => ({ ...transfer, created_at: iso(transfer.created_at), completed_at: transfer.completed_at ? iso(transfer.completed_at) : null })) }; }); } async update(workspaceId: string, userId: string, id: string, key: string, expected: number, input: UpdateCollectionItemRequest) { return this.idempotency.run({ workspaceId, userId, method: 'PATCH', path: `/api/collection-items/${id}`, key, request: { if_match: expected, ...input } }, async (tx) => { const result = await tx.query(`UPDATE atlas.collection_items SET note = $1, revision = revision + 1, updated_at = now() WHERE id = $2 AND deleted_at IS NULL AND revision = $3`, [input.note, id, expected]); - if (!result.rowCount) await this.versionConflict(tx, id, expected); + if (!result.rowCount) await throwRevisionConflict(tx, 'collection_item', id, expected); return { status: 200, body: { data: await this.requireItem(tx, id) } }; }); } @@ -57,7 +58,6 @@ export class CollectionService { private async event(tx: DatabaseTransaction, aggregateId: string, type: string, payload: unknown) { await tx.query(`INSERT INTO atlas.outbox_events (workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key) VALUES (current_setting('app.workspace_id')::uuid, 'collection_item', $1, $2, $3, $4)`, [aggregateId, type, payload, `${type}:${aggregateId}`]); } private async requireHeaderPlatform(tx: DatabaseTransaction, id: string) { const row = await tx.query(`SELECT id FROM atlas.platforms WHERE id = $1 AND status = 'active' AND coalesce((capabilities->>'can_be_header_context')::boolean, false)`, [id]); if (!row.rows[0]) throw new ApiError(422, 'PLATFORM_UNAVAILABLE', '当前平台不可用于 ERP 上下文'); } private async requireItem(tx: DatabaseTransaction, id: string) { const row = await tx.query(`${selectRow} WHERE c.id = $1 AND c.deleted_at IS NULL ${collectionGroupBy}`, [id]); if (!row.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '采集记录不存在'); return toItem(row.rows[0]); } - private async versionConflict(tx: DatabaseTransaction, id: string, expected: number) { const row = await tx.query<{ revision: number }>('SELECT revision FROM atlas.collection_items WHERE id = $1 AND deleted_at IS NULL', [id]); if (!row.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '采集记录不存在'); if (row.rows[0].revision !== expected) throw new ApiError(409, 'VERSION_CONFLICT', '采集记录已被更新', [{ field: 'If-Match', code: 'VERSION_CONFLICT', message: `当前版本为 ${row.rows[0].revision}`, current_revision: row.rows[0].revision }]); } } const selectRow = `SELECT c.id, product.id AS product_id, c.source_kind, p.code AS platform_code, c.source_url, c.source_title, c.source_min_price::text, c.source_max_price::text, c.source_currency_code, c.note, c.status, c.created_at, c.updated_at, c.revision, count(DISTINCT v.id)::text AS variant_count, min(t.status) AS transfer_status FROM atlas.collection_items c JOIN atlas.products product ON product.collection_item_id = c.id LEFT JOIN atlas.platforms p ON p.id = c.source_platform_id LEFT JOIN atlas.product_variants v ON v.product_id = product.id AND v.deleted_at IS NULL LEFT JOIN atlas.collection_asset_transfers t ON t.collection_item_id = c.id AND t.source_snapshot_revision = c.source_snapshot_revision`; const collectionGroupBy = 'GROUP BY c.id, product.id, p.code'; diff --git a/apps/api/src/common/revision.ts b/apps/api/src/common/revision.ts new file mode 100644 index 0000000..fc9145f --- /dev/null +++ b/apps/api/src/common/revision.ts @@ -0,0 +1,50 @@ +import type { DatabaseTransaction } from '@atlas/database'; +import { ApiError } from './api-error.js'; + +/** + * Shared optimistic-lock conflict response. Versioned writes still keep their + * domain-specific SET clauses, but they must use a conditional `revision = + * expected` update and this helper when no row was affected. + */ +const resources = { + collection_item: { + table: 'atlas.collection_items', + label: '采集记录', + active: ' AND deleted_at IS NULL', + }, + product: { table: 'atlas.products', label: '商品', active: '' }, + product_platform_profile: { + table: 'atlas.product_platform_profiles', + label: '商品发布配置', + active: '', + }, +} as const; + +export async function throwRevisionConflict( + transaction: DatabaseTransaction, + resource: keyof typeof resources, + id: string, + expected: number, +): Promise { + const target = resources[resource]; + const current = await transaction.query<{ revision: number }>( + `SELECT revision FROM ${target.table} WHERE id = $1${target.active}`, + [id], + ); + if (!current.rows[0]) + throw new ApiError(404, 'RESOURCE_NOT_FOUND', `${target.label}不存在`); + if (current.rows[0].revision !== expected) { + throw new ApiError(409, 'VERSION_CONFLICT', `${target.label}已被更新`, [ + { + field: 'If-Match', + code: 'VERSION_CONFLICT', + message: `当前版本为 ${current.rows[0].revision}`, + current_revision: current.rows[0].revision, + }, + ]); + } + // A conditional write with a matching version affecting no rows is a + // resource-state error (for example, a concurrent soft delete), not a + // successful retry. + throw new ApiError(409, 'VERSION_CONFLICT', `${target.label}状态已变化`); +} diff --git a/apps/api/src/extension/extension.service.ts b/apps/api/src/extension/extension.service.ts index 287fa3f..3c892bc 100644 --- a/apps/api/src/extension/extension.service.ts +++ b/apps/api/src/extension/extension.service.ts @@ -223,7 +223,7 @@ export class ExtensionService { if (!Number.isSafeInteger(snapshotRevision) || snapshotRevision < 1) throw new Error('采集来源快照版本更新失败'); await tx.query( - `UPDATE atlas.products SET source_title = $1, updated_at = now(), version = version + 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], ); // The historical task rows must remain auditable after replacing the diff --git a/apps/api/src/product-editor/product-editor.controller.ts b/apps/api/src/product-editor/product-editor.controller.ts new file mode 100644 index 0000000..37ffb3a --- /dev/null +++ b/apps/api/src/product-editor/product-editor.controller.ts @@ -0,0 +1,92 @@ +import { Body, Controller, Get, Headers, Inject, Param, Post, Put, Req, Res } from '@nestjs/common'; +import { z } from 'zod'; +import { saveProductEditorDraftRequestSchema, type Workspace } from '@atlas/contracts'; +import { ApiError } from '../common/api-error.js'; +import type { AuthenticatedRequest } from '../auth/auth.guard.js'; +import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js'; +import { ProductEditorService } from './product-editor.service.js'; + +type Response = { status: (value: number) => Response }; + +@Controller() +export class ProductEditorController { + public constructor( + @Inject(ProductEditorService) + private readonly editor: ProductEditorService, + ) {} + + @Post('collection-items/:collectionItemId/editor-contexts') + async enter( + @Param('collectionItemId') collectionItemId: 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.enter( + workspace.id, + request.auth.userId, + requiredUuid('collection_item_id', collectionItemId), + requiredUuid('X-Atlas-Platform-Id', platformId), + requiredUuid('Idempotency-Key', key), + ); + response.status(result.status); + return result.body; + } + + @Get('products/:productId/editor') + async read( + @Param('productId') productId: string, + @Headers('x-atlas-platform-id') platformId: string | undefined, + @CurrentWorkspace() workspace: Workspace, + ) { + return { + data: await this.editor.read( + workspace.id, + requiredUuid('product_id', productId), + requiredUuid('X-Atlas-Platform-Id', platformId), + ), + }; + } + + @Put('products/:productId/editor-draft') + async save( + @Param('productId') productId: string, + @Body() body: unknown, + @Headers('x-atlas-platform-id') platformId: string | undefined, + @Headers('idempotency-key') key: string | undefined, + @Headers('if-match') ifMatch: string | undefined, + @Req() request: AuthenticatedRequest, + @CurrentWorkspace() workspace: Workspace, + ) { + const parsed = saveProductEditorDraftRequestSchema.safeParse(body); + if (!parsed.success) { + throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', parsed.error.issues.map((issue) => ({ + field: issue.path.join('.'), code: 'INVALID_FIELD', message: issue.message, + }))); + } + return (await this.editor.save( + workspace.id, + request.auth.userId, + requiredUuid('product_id', productId), + requiredUuid('X-Atlas-Platform-Id', platformId), + requiredUuid('Idempotency-Key', key), + parseEditorRevision(ifMatch), + parsed.data, + )).body; + } +} + +function requiredUuid(field: string, value: string | undefined) { + if (value && z.uuid().safeParse(value).success) return value; + throw new ApiError(400, 'VALIDATION_FAILED', '请求参数不符合要求', [ + { field, code: 'INVALID_FIELD', message: `${field} 必须是 UUID` }, + ]); +} + +function parseEditorRevision(value: string | undefined) { + const match = value?.match(/^"([1-9]\d*):([1-9]\d*)"$/); + if (!match) throw new ApiError(400, 'IF_MATCH_REQUIRED', '请求缺少或格式错误的 If-Match'); + return { product: Number(match[1]), profile: Number(match[2]) }; +} diff --git a/apps/api/src/product-editor/product-editor.module.ts b/apps/api/src/product-editor/product-editor.module.ts new file mode 100644 index 0000000..3c7dafc --- /dev/null +++ b/apps/api/src/product-editor/product-editor.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { IdempotencyService } from '../common/idempotency.service.js'; +import { ProductEditorController } from './product-editor.controller.js'; +import { ProductEditorService } from './product-editor.service.js'; + +@Module({ + controllers: [ProductEditorController], + providers: [IdempotencyService, ProductEditorService], +}) +export class ProductEditorModule {} diff --git a/apps/api/src/product-editor/product-editor.service.ts b/apps/api/src/product-editor/product-editor.service.ts new file mode 100644 index 0000000..6873484 --- /dev/null +++ b/apps/api/src/product-editor/product-editor.service.ts @@ -0,0 +1,550 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { + ProductEditorContext, + ProductEditorSnapshot, + SaveProductEditorDraftRequest, +} from '@atlas/contracts'; +import type { Database, DatabaseTransaction } from '@atlas/database'; +import { ApiError } from '../common/api-error.js'; +import { IdempotencyService } from '../common/idempotency.service.js'; +import { DATABASE } from '../database/database.module.js'; + +type ProductRow = { + id: string; + collection_item_id: string; + revision: number; + source_title: string | null; + brand: string | null; + model: string | null; + barcode: string | null; + purchase_url: string | null; + supply_note: string | null; +}; +type ProfileRow = { + id: string; + revision: number; + platform_id: string; + market_code: string | null; + fulfillment: string; + title: string | null; + description: string | null; + tags: unknown; + platform_product_category_id: string | null; + platform_type_id: string | null; + category_source: string | null; + target_store_id: string; + store_name: string; + currency_code: string; +}; + +@Injectable() +export class ProductEditorService { + public constructor( + @Inject(IdempotencyService) + private readonly idempotency: IdempotencyService, + @Inject(DATABASE) private readonly database: Database, + ) {} + + async enter( + workspaceId: string, + userId: string, + collectionItemId: string, + platformId: string, + key: string, + ) { + return this.idempotency.run( + { + workspaceId, + userId, + method: 'POST', + path: `/api/collection-items/${collectionItemId}/editor-contexts`, + key, + request: { platform_id: platformId }, + }, + async (transaction) => { + await this.requireTargetPlatform(transaction, platformId); + const product = await this.requireProductByCollectionItem( + transaction, + collectionItemId, + ); + const existing = await this.findProfile( + transaction, + product.id, + platformId, + ); + if (existing) { + await this.ensureListings( + transaction, + product.id, + existing, + ); + return { + status: 200, + body: { + data: this.toContext(collectionItemId, product.id, existing), + }, + }; + } + const store = await this.requireDefaultStore( + transaction, + platformId, + ); + const created = await transaction.query<{ id: string }>( + `INSERT INTO atlas.product_platform_profiles + (workspace_id, product_id, platform_id, target_store_id, market_code) + SELECT current_setting('app.workspace_id')::uuid, $1, $2, $3, + nullif(p.capabilities->>'default_market_code', '') + FROM atlas.platforms p WHERE p.id = $2 + ON CONFLICT (workspace_id, product_id, platform_id) DO NOTHING + RETURNING id`, + [product.id, platformId, store.id], + ); + const profile = + created.rows[0] + ? await this.requireProfile(transaction, created.rows[0].id) + : await this.findProfile(transaction, product.id, platformId); + if (!profile) throw new Error('商品编辑上下文创建后无法读取发布配置'); + await this.ensureListings(transaction, product.id, profile); + return { + status: created.rows[0] ? 201 : 200, + body: { + data: this.toContext(collectionItemId, product.id, profile), + }, + }; + }, + ); + } + + async read( + workspaceId: string, + productId: string, + platformId: string, + ): Promise { + return this.database.workspaceTransaction(workspaceId, async (transaction) => { + await this.requireTargetPlatform(transaction, platformId); + const product = await this.requireProduct(transaction, productId); + const profile = await this.findProfile(transaction, productId, platformId); + if (!profile) + throw new ApiError(409, 'EDITOR_CONTEXT_REQUIRED', '请先从采集箱进入商品编辑器'); + const variants = await transaction.query<{ + id: string; source_variant_key: string | null; source_sku: string | null; + display_name: string | null; sort_order: number; purchase_cost_cny: string | null; listing_id: string; + offer_id: string | null; status: string; selling_price: string | null; + list_price: string | null; currency_code: string | null; + }>( + `SELECT v.id, v.source_variant_key, v.source_sku, v.display_name, v.sort_order, + v.purchase_cost_cny::text AS purchase_cost_cny, l.id AS listing_id, l.offer_id, l.status, l.selling_price::text, + l.list_price::text, l.currency_code + FROM atlas.product_variants v + JOIN atlas.product_variant_platform_listings l + ON l.product_variant_id = v.id AND l.profile_id = $1 + WHERE v.product_id = $2 AND v.deleted_at IS NULL + ORDER BY v.sort_order, v.id`, + [profile.id, productId], + ); + const attributes = await transaction.query<{ + id: string; scope: 'common' | 'variant'; product_variant_id: string | null; + platform_attribute_id: string; complex_id: string; values: unknown; + language_code: string | null; source: string; confirmation_status: string; + }>( + `SELECT id, scope, product_variant_id, platform_attribute_id, complex_id, + values, language_code, source, confirmation_status + FROM atlas.product_attributes WHERE profile_id = $1 + ORDER BY created_at, id`, + [profile.id], + ); + const unmapped = await transaction.query<{ + id: string; product_variant_id: string | null; + original_platform_category_id: string | null; + original_platform_attribute_id: string; original_complex_id: string; + original_name: string; values: unknown; language_code: string | null; + reason: string; status: string; + }>( + `SELECT id, product_variant_id, original_platform_category_id, + original_platform_attribute_id, original_complex_id, original_name, + values, language_code, reason, status + FROM atlas.product_unmapped_attributes + WHERE profile_id = $1 AND status = 'pending' ORDER BY created_at, id`, + [profile.id], + ); + return { + product: { + id: product.id, + revision: Number(product.revision), + common: { + source_title: product.source_title, + brand: product.brand, + model: product.model, + barcode: product.barcode, + purchase_url: product.purchase_url, + supply_note: product.supply_note, + }, + }, + profile: { + id: profile.id, + revision: Number(profile.revision), + platform_id: profile.platform_id, + market_code: profile.market_code, + fulfillment: profile.fulfillment, + target_store: { + id: profile.target_store_id, + name: profile.store_name, + currency_code: profile.currency_code, + }, + title: profile.title, + description: profile.description, + tags: Array.isArray(profile.tags) + ? profile.tags.filter((value): value is string => typeof value === 'string') + : [], + category: { + id: profile.platform_product_category_id, + platform_type_id: profile.platform_type_id, + source: profile.category_source, + }, + }, + variants: variants.rows.map((variant) => ({ + id: variant.id, + source_variant_key: variant.source_variant_key, + source_sku: variant.source_sku, + display_name: variant.display_name, + sort_order: variant.sort_order, + purchase_cost_cny: variant.purchase_cost_cny, + listing: { + id: variant.listing_id, + offer_id: variant.offer_id, + status: variant.status, + selling_price: variant.selling_price, + list_price: variant.list_price, + currency_code: variant.currency_code, + }, + })), + attributes: attributes.rows.map((attribute) => ({ + id: attribute.id, + scope: attribute.scope, + variant_id: attribute.product_variant_id, + platform_attribute_id: attribute.platform_attribute_id, + complex_id: attribute.complex_id, + values: attribute.values, + language_code: attribute.language_code, + source: attribute.source, + confirmation_status: attribute.confirmation_status, + })), + unmapped_attributes: unmapped.rows.map((attribute) => ({ + id: attribute.id, + variant_id: attribute.product_variant_id, + original_platform_category_id: attribute.original_platform_category_id, + original_platform_attribute_id: attribute.original_platform_attribute_id, + original_complex_id: attribute.original_complex_id, + original_name: attribute.original_name, + values: attribute.values, + language_code: attribute.language_code, + reason: attribute.reason, + status: attribute.status, + })), + warnings: unmapped.rows.length + ? [{ code: 'UNMAPPED_ATTRIBUTES_PENDING', message: '存在因类目变化保留的未映射属性' }] + : [], + }; + }); + } + + async save( + workspaceId: string, + userId: string, + productId: string, + platformId: string, + key: string, + expected: { product: number; profile: number }, + input: SaveProductEditorDraftRequest, + ) { + return this.idempotency.run( + { + workspaceId, + userId, + method: 'PUT', + path: `/api/products/${productId}/editor-draft`, + key, + request: { if_match: expected, platform_id: platformId, ...input }, + }, + async (transaction) => { + await this.requireTargetPlatform(transaction, platformId); + const product = await this.requireProduct(transaction, productId); + const profile = await this.findProfile(transaction, productId, platformId); + if (!profile) throw new ApiError(409, 'EDITOR_CONTEXT_REQUIRED', '请先从采集箱进入商品编辑器'); + await this.requireEditableStore(transaction, input.profile.target_store_id, platformId); + await this.ensureVariantIdsBelongToProduct(transaction, productId, input); + await this.assertOfferIdsAvailable(transaction, productId, platformId, input); + + const updatedProfile = await transaction.query<{ revision: number }>( + `UPDATE atlas.product_platform_profiles + SET target_store_id = $1, fulfillment = $2, title = $3, description = $4, + tags = $5::jsonb, revision = revision + 1, updated_at = now() + WHERE id = $6 AND product_id = $7 AND platform_id = $8 AND revision = $9 + RETURNING revision`, + [input.profile.target_store_id, input.profile.fulfillment, input.profile.title, + input.profile.description, JSON.stringify(input.profile.tags), profile.id, + productId, platformId, expected.profile], + ); + if (!updatedProfile.rows[0]) await this.throwProfileRevisionConflict(transaction, profile.id, expected.profile); + const updatedProduct = await transaction.query<{ revision: number }>( + `UPDATE atlas.products + SET source_title = $1, brand = $2, model = $3, barcode = $4, + purchase_url = $5, supply_note = $6, revision = revision + 1, updated_at = now() + WHERE id = $7 AND revision = $8 RETURNING revision`, + [input.common.source_title, input.common.brand, input.common.model, + input.common.barcode, input.common.purchase_url, input.common.supply_note, + productId, expected.product], + ); + if (!updatedProduct.rows[0]) await this.throwProductRevisionConflict(transaction, productId, expected.product); + await transaction.query( + `UPDATE atlas.collection_items SET source_title = $1, revision = revision + 1, updated_at = now() + WHERE id = $2`, + [input.common.source_title, product.collection_item_id], + ); + + const createdVariants: Array<{ client_key: string; id: string }> = []; + const retainedIds: string[] = []; + for (let index = 0; index < input.variants.length; index += 1) { + const variant = input.variants[index]; + if (variant.id) { + retainedIds.push(variant.id); + await transaction.query( + `UPDATE atlas.product_variants + SET display_name = $1, source_sku = $2, purchase_cost_cny = $3, + sort_order = $4, updated_at = now() + WHERE id = $5 AND product_id = $6 AND deleted_at IS NULL`, + [variant.display_name, variant.source_sku, variant.purchase_cost_cny, + index, variant.id, productId], + ); + await transaction.query( + `UPDATE atlas.product_variant_platform_listings + SET offer_id = $1, store_id = $2, revision = revision + 1, updated_at = now() + WHERE profile_id = $3 AND product_variant_id = $4 AND status <> 'imported'`, + [variant.offer_id, input.profile.target_store_id, profile.id, variant.id], + ); + continue; + } + const copied = variant.copy_from_variant_id + ? await transaction.query<{ specification_values: unknown }>( + `SELECT specification_values FROM atlas.product_variants + WHERE id = $1 AND product_id = $2 AND deleted_at IS NULL`, + [variant.copy_from_variant_id, productId], + ) + : null; + const inserted = await transaction.query<{ id: string }>( + `INSERT INTO atlas.product_variants + (workspace_id, product_id, display_name, source_sku, purchase_cost_cny, + specification_values, sort_order) + VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, + $5::jsonb, $6) RETURNING id`, + [productId, variant.display_name, variant.source_sku, variant.purchase_cost_cny, + JSON.stringify(copied?.rows[0]?.specification_values ?? []), index], + ); + const id = inserted.rows[0].id; + retainedIds.push(id); + createdVariants.push({ client_key: variant.client_key!, id }); + await transaction.query( + `INSERT INTO atlas.product_variant_platform_listings + (workspace_id, product_id, profile_id, platform_id, product_variant_id, store_id, offer_id, currency_code) + SELECT current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6, s.currency_code + FROM atlas.stores s WHERE s.id = $5`, + [productId, profile.id, platformId, id, input.profile.target_store_id, variant.offer_id], + ); + } + // A removed, never-imported SKU must release its draft offer_id; + // otherwise a replacement SKU in the same store would hit the + // durable uniqueness rule despite the user having deleted it. + await transaction.query( + `UPDATE atlas.product_variant_platform_listings l + SET offer_id = NULL, revision = revision + 1, updated_at = now() + FROM atlas.product_variants v + WHERE l.profile_id = $1 AND l.product_variant_id = v.id + AND v.product_id = $2 AND v.deleted_at IS NULL + AND NOT (v.id = ANY($3::uuid[])) AND l.status <> 'imported'`, + [profile.id, productId, retainedIds], + ); + await transaction.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, retainedIds], + ); + return { + status: 200, + body: { + data: { + product_revision: Number(updatedProduct.rows[0].revision), + profile_revision: Number(updatedProfile.rows[0].revision), + created_variants: createdVariants, + warnings: [], + }, + }, + }; + }, + ); + } + + private async ensureListings( + transaction: DatabaseTransaction, + productId: string, + profile: ProfileRow, + ) { + await transaction.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, $2, $3, v.id, $4, s.currency_code + FROM atlas.product_variants v + JOIN atlas.stores s ON s.id = $4 + WHERE v.product_id = $1 AND v.deleted_at IS NULL + ON CONFLICT (workspace_id, profile_id, product_variant_id) DO NOTHING`, + [productId, profile.id, profile.platform_id, profile.target_store_id], + ); + } + + private async requireTargetPlatform(transaction: DatabaseTransaction, platformId: string) { + const platform = await transaction.query( + `SELECT id FROM atlas.platforms + WHERE id = $1 AND status = 'active' + AND coalesce((capabilities->>'can_be_target')::boolean, false) + AND coalesce((capabilities->>'can_be_header_context')::boolean, false)`, + [platformId], + ); + if (!platform.rows[0]) + throw new ApiError(422, 'PLATFORM_UNAVAILABLE', '当前平台不可用于商品编辑'); + } + + private async requireDefaultStore(transaction: DatabaseTransaction, platformId: string) { + const store = await transaction.query<{ id: string }>( + `SELECT id FROM atlas.stores + WHERE platform_id = $1 AND deleted_at IS NULL AND is_default + AND status = 'active' AND credential_status = 'valid' + LIMIT 1`, + [platformId], + ); + if (!store.rows[0]) + throw new ApiError(422, 'DEFAULT_STORE_REQUIRED', '请先添加并验证当前平台的默认店铺'); + return store.rows[0]; + } + + private async requireEditableStore(transaction: DatabaseTransaction, storeId: string, platformId: string) { + const store = await transaction.query( + `SELECT id FROM atlas.stores + WHERE id = $1 AND platform_id = $2 AND deleted_at IS NULL + AND status = 'active' AND credential_status = 'valid'`, + [storeId, platformId], + ); + if (!store.rows[0]) throw new ApiError(422, 'STORE_UNAVAILABLE', '目标店铺不存在、未验证或不属于当前平台'); + } + + private async ensureVariantIdsBelongToProduct( + transaction: DatabaseTransaction, + productId: string, + input: SaveProductEditorDraftRequest, + ) { + const ids = input.variants.flatMap((variant) => [variant.id, variant.copy_from_variant_id].filter((id): id is string => !!id)); + if (!ids.length) return; + const owned = await transaction.query<{ id: string }>( + `SELECT id FROM atlas.product_variants + WHERE product_id = $1 AND deleted_at IS NULL AND id = ANY($2::uuid[])`, + [productId, ids], + ); + if (new Set(owned.rows.map((row) => row.id)).size !== new Set(ids).size) { + throw new ApiError(422, 'VARIANT_PRODUCT_MISMATCH', 'SKU 不属于当前商品'); + } + } + + private async assertOfferIdsAvailable( + transaction: DatabaseTransaction, + productId: string, + platformId: string, + input: SaveProductEditorDraftRequest, + ) { + const offers = input.variants + .map((variant) => variant.offer_id) + .filter((offer): offer is string => !!offer); + if (new Set(offers).size !== offers.length) { + throw new ApiError(422, 'OFFER_ID_DUPLICATE', '同一商品的 SKU 货号不能重复'); + } + if (!offers.length) return; + const conflicts = await transaction.query<{ offer_id: string }>( + `SELECT offer_id FROM atlas.product_variant_platform_listings + WHERE platform_id = $1 AND store_id = $2 AND product_id <> $3 + AND offer_id = ANY($4::text[])`, + [platformId, input.profile.target_store_id, productId, offers], + ); + if (conflicts.rows[0]) { + throw new ApiError(422, 'OFFER_ID_ALREADY_EXISTS', '目标店铺中已存在相同货号', [{ + field: 'variants', code: 'OFFER_ID_ALREADY_EXISTS', + message: `货号 ${conflicts.rows[0].offer_id} 已被其他商品使用`, + }]); + } + } + + private async throwProductRevisionConflict(transaction: DatabaseTransaction, productId: string, expected: number): Promise { + const row = await transaction.query<{ revision: number }>('SELECT revision FROM atlas.products WHERE id = $1', [productId]); + if (!row.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在'); + throw new ApiError(409, 'VERSION_CONFLICT', '商品已被更新', [{ field: 'If-Match', code: 'VERSION_CONFLICT', message: `当前商品版本为 ${row.rows[0].revision}`, current_revision: row.rows[0].revision, expected_revision: expected }]); + } + + private async throwProfileRevisionConflict(transaction: DatabaseTransaction, profileId: string, expected: number): Promise { + const row = await transaction.query<{ revision: number }>('SELECT revision FROM atlas.product_platform_profiles WHERE id = $1', [profileId]); + if (!row.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品发布配置不存在'); + throw new ApiError(409, 'VERSION_CONFLICT', '商品发布配置已被更新', [{ field: 'If-Match', code: 'VERSION_CONFLICT', message: `当前发布配置版本为 ${row.rows[0].revision}`, current_revision: row.rows[0].revision, expected_revision: expected }]); + } + + private async requireProductByCollectionItem(transaction: DatabaseTransaction, collectionItemId: string) { + const product = await transaction.query( + `SELECT p.id, p.collection_item_id, p.revision, p.source_title, p.brand, + p.model, p.barcode, p.purchase_url, p.supply_note + FROM atlas.products p JOIN atlas.collection_items c ON c.id = p.collection_item_id + WHERE c.id = $1 AND c.deleted_at IS NULL`, + [collectionItemId], + ); + if (!product.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '采集记录不存在'); + return product.rows[0]; + } + + private async requireProduct(transaction: DatabaseTransaction, productId: string) { + const product = await transaction.query( + `SELECT p.id, p.collection_item_id, p.revision, p.source_title, p.brand, + p.model, p.barcode, p.purchase_url, p.supply_note + FROM atlas.products p JOIN atlas.collection_items c ON c.id = p.collection_item_id + WHERE p.id = $1 AND c.deleted_at IS NULL`, + [productId], + ); + if (!product.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在'); + return product.rows[0]; + } + + private async findProfile(transaction: DatabaseTransaction, productId: string, platformId: string) { + const profile = await transaction.query(`${profileSelect} + WHERE p.product_id = $1 AND p.platform_id = $2`, [productId, platformId]); + return profile.rows[0] ?? null; + } + + private async requireProfile(transaction: DatabaseTransaction, profileId: string) { + const profile = await transaction.query(`${profileSelect} WHERE p.id = $1`, [profileId]); + if (!profile.rows[0]) throw new Error('商品发布配置不存在'); + return profile.rows[0]; + } + + private toContext(collectionItemId: string, productId: string, profile: ProfileRow): ProductEditorContext { + return { + collection_item_id: collectionItemId, + product_id: productId, + profile_id: profile.id, + target_store: { + id: profile.target_store_id, + name: profile.store_name, + currency_code: profile.currency_code, + }, + editor_path: `/products/${productId}/editor`, + }; + } +} + +const profileSelect = `SELECT p.id, p.revision, p.platform_id, p.market_code, + p.fulfillment, p.title, p.description, p.tags, p.platform_product_category_id, + p.platform_type_id, p.category_source, p.target_store_id, s.name AS store_name, + s.currency_code + FROM atlas.product_platform_profiles p + JOIN atlas.stores s ON s.id = p.target_store_id`; diff --git a/apps/api/test/product-editor.e2e.test.ts b/apps/api/test/product-editor.e2e.test.ts new file mode 100644 index 0000000..ec76384 --- /dev/null +++ b/apps/api/test/product-editor.e2e.test.ts @@ -0,0 +1,291 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import type { AddressInfo } from 'node:net'; +import type { Database } from '@atlas/database'; +import type { NestExpressApplication } from '@nestjs/platform-express'; +import { afterAll, beforeAll, describe, it } from 'vitest'; +import { DATABASE } from '../src/database/database.module.js'; +import { createApplication } from '../src/main.js'; + +let app: NestExpressApplication; +let database: Database; +let baseUrl = ''; +let workspaceId = ''; +let userId = ''; +let token = ''; +let platformId = ''; +let first: { collectionItemId: string; productId: string; profileId: string }; + +async function request(path: string, init: RequestInit = {}) { + const response = await fetch(`${baseUrl}${path}`, { + ...init, + headers: { 'Content-Type': 'application/json', ...init.headers }, + }); + return { + response, + body: response.status === 204 ? undefined : ((await response.json()) as any), + }; +} + +function headers(extra: Record = {}) { + return { Authorization: `Bearer ${token}`, ...extra }; +} + +async function createCollectionItem(title: string) { + const created = await request('/api/collection-items', { + method: 'POST', + headers: headers({ 'Idempotency-Key': randomUUID() }), + body: JSON.stringify({ title }), + }); + assert.equal(created.response.status, 201); + return { + collectionItemId: created.body.data.collection_item.id as string, + productId: created.body.data.product_id as string, + }; +} + +describe('商品编辑 M3.0 / M3.1 HTTP 集成测试', () => { + beforeAll(async () => { + Object.assign(process.env, { + NODE_ENV: 'test', + SWAGGER_ENABLED: 'false', + AUTH_REGISTER_IP_LIMIT: '100', + AUTH_REGISTER_EMAIL_LIMIT: '100', + }); + const created = await createApplication(); + app = created.app; + database = app.get(DATABASE); + await app.listen(0, '127.0.0.1'); + baseUrl = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`; + const registration = await request('/api/auth/register', { + method: 'POST', + body: JSON.stringify({ + email: `editor-${randomUUID()}@example.test`, + password: 'correct-horse-battery-staple-123', + password_confirmation: 'correct-horse-battery-staple-123', + }), + }); + assert.equal(registration.response.status, 201); + userId = registration.body.data.user.id; + workspaceId = registration.body.data.workspace.id; + token = registration.body.data.access_token; + const platforms = await request('/api/platforms', { headers: headers() }); + platformId = platforms.body.data[0].id; + }); + + afterAll(async () => { + await database.workspaceTransaction(workspaceId, async (tx) => { + await tx.query('DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1', [workspaceId]); + await tx.query('DELETE FROM atlas.product_unmapped_attributes WHERE workspace_id = $1', [workspaceId]); + await tx.query('DELETE FROM atlas.product_attributes WHERE workspace_id = $1', [workspaceId]); + await tx.query('DELETE FROM atlas.product_variant_platform_listings WHERE workspace_id = $1', [workspaceId]); + await tx.query('DELETE FROM atlas.collection_asset_transfers WHERE workspace_id = $1', [workspaceId]); + await tx.query('DELETE FROM atlas.product_variants 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.products WHERE workspace_id = $1', [workspaceId]); + await tx.query('DELETE FROM atlas.collection_items WHERE workspace_id = $1', [workspaceId]); + await tx.query('DELETE FROM atlas.stores WHERE workspace_id = $1', [workspaceId]); + }); + await database.query('DELETE FROM atlas.workspace_memberships WHERE user_id = $1', [userId]); + await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [workspaceId]); + await database.query('DELETE FROM atlas.users WHERE id = $1', [userId]); + await app.close(); + }); + + it('仅在存在已验证默认店铺时创建平台编辑上下文,并返回稳定读模型', async () => { + const created = await createCollectionItem('编辑器测试商品 A'); + const missingStore = await request(`/api/collection-items/${created.collectionItemId}/editor-contexts`, { + method: 'POST', + headers: headers({ 'X-Atlas-Platform-Id': platformId, 'Idempotency-Key': randomUUID() }), + }); + assert.equal(missingStore.response.status, 422); + assert.equal(missingStore.body.error.code, 'DEFAULT_STORE_REQUIRED'); + 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, 'M3 测试店铺', $2, 'RUB', true, 'active', 'valid')`, + [platformId, `editor-${randomUUID()}`], + ); + }); + const entered = await request(`/api/collection-items/${created.collectionItemId}/editor-contexts`, { + method: 'POST', + headers: headers({ 'X-Atlas-Platform-Id': platformId, 'Idempotency-Key': randomUUID() }), + }); + assert.equal(entered.response.status, 201); + first = { + collectionItemId: created.collectionItemId, + productId: created.productId, + profileId: entered.body.data.profile_id, + }; + const enteredAgain = await request(`/api/collection-items/${created.collectionItemId}/editor-contexts`, { + method: 'POST', + headers: headers({ 'X-Atlas-Platform-Id': platformId, 'Idempotency-Key': randomUUID() }), + }); + assert.equal(enteredAgain.response.status, 200); + assert.equal(enteredAgain.body.data.profile_id, first.profileId); + const editor = await request(`/api/products/${first.productId}/editor`, { + headers: headers({ 'X-Atlas-Platform-Id': platformId }), + }); + assert.equal(editor.response.status, 200); + assert.equal(editor.body.data.product.id, first.productId); + assert.equal(editor.body.data.profile.id, first.profileId); + assert.equal(editor.body.data.variants.length, 1); + assert.equal(editor.body.data.variants[0].listing.status, 'not_listed'); + }); + + it('数据库拒绝跨商品 SKU 与 profile 的关联,并保留未映射属性草稿', async () => { + const second = await createCollectionItem('编辑器测试商品 B'); + const secondContext = await request(`/api/collection-items/${second.collectionItemId}/editor-contexts`, { + method: 'POST', + headers: headers({ 'X-Atlas-Platform-Id': platformId, 'Idempotency-Key': randomUUID() }), + }); + assert.equal(secondContext.response.status, 201); + const link = await database.workspaceTransaction(workspaceId, async (tx) => { + const secondVariant = await tx.query<{ id: string }>( + 'SELECT id FROM atlas.product_variants WHERE product_id = $1 AND deleted_at IS NULL', + [second.productId], + ); + const firstProfile = await tx.query<{ target_store_id: string }>( + 'SELECT target_store_id FROM atlas.product_platform_profiles WHERE id = $1', + [first.profileId], + ); + return { + variantId: secondVariant.rows[0].id, + storeId: firstProfile.rows[0].target_store_id, + }; + }); + await assert.rejects( + database.workspaceTransaction(workspaceId, async (tx) => + tx.query( + `INSERT INTO atlas.product_variant_platform_listings + (workspace_id, product_id, profile_id, platform_id, product_variant_id, store_id) + VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5)`, + [second.productId, first.profileId, platformId, link.variantId, link.storeId], + ), + ), + { code: '23503' }, + ); + await database.workspaceTransaction(workspaceId, async (tx) => { + await tx.query( + `INSERT INTO atlas.product_unmapped_attributes + (workspace_id, product_id, profile_id, platform_id, original_platform_attribute_id, + original_name, values, reason) + VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, 'legacy-material', + '旧类目材质', '["棉"]'::jsonb, 'category_changed')`, + [first.productId, first.profileId, platformId], + ); + }); + const editor = await request(`/api/products/${first.productId}/editor`, { + headers: headers({ 'X-Atlas-Platform-Id': platformId }), + }); + assert.equal(editor.response.status, 200); + assert.equal(editor.body.data.unmapped_attributes.length, 1); + assert.equal(editor.body.data.unmapped_attributes[0].original_name, '旧类目材质'); + assert.equal(editor.body.data.warnings[0].code, 'UNMAPPED_ATTRIBUTES_PENDING'); + }); + + it('原子保存草稿会更新双版本、创建 SKU,并拒绝过期版本覆盖', async () => { + const before = await request(`/api/products/${first.productId}/editor`, { + headers: headers({ 'X-Atlas-Platform-Id': platformId }), + }); + const existing = before.body.data.variants[0]; + const payload = { + common: { + source_title: '已由编辑器确认的商品名', brand: 'Atlas', model: null, + barcode: null, purchase_url: null, supply_note: 'M3.1 集成测试', + }, + profile: { + target_store_id: before.body.data.profile.target_store.id, + fulfillment: 'rfbs', title: 'Ozon 测试标题', description: null, + tags: ['测试', '草稿'], + }, + variants: [ + { + id: existing.id, client_key: null, copy_from_variant_id: null, + display_name: '默认 SKU(已编辑)', source_sku: existing.source_sku, + purchase_cost_cny: '88.50', offer_id: 'm3-variant-a', + }, + { + id: null, client_key: 'new-variant-b', copy_from_variant_id: existing.id, + display_name: '新增 SKU', source_sku: null, + purchase_cost_cny: '99.00', offer_id: 'm3-variant-b', + }, + ], + }; + const key = randomUUID(); + const saved = await request(`/api/products/${first.productId}/editor-draft`, { + method: 'PUT', + headers: headers({ + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': key, + 'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`, + }), + body: JSON.stringify(payload), + }); + assert.equal(saved.response.status, 200); + assert.equal(saved.body.data.created_variants.length, 1); + assert.equal(saved.body.data.product_revision, before.body.data.product.revision + 1); + assert.equal(saved.body.data.profile_revision, before.body.data.profile.revision + 1); + const replay = await request(`/api/products/${first.productId}/editor-draft`, { + method: 'PUT', + headers: headers({ + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': key, + 'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`, + }), + body: JSON.stringify(payload), + }); + assert.deepEqual(replay.body, saved.body); + const after = await request(`/api/products/${first.productId}/editor`, { + headers: headers({ 'X-Atlas-Platform-Id': platformId }), + }); + assert.equal(after.body.data.product.common.source_title, '已由编辑器确认的商品名'); + assert.equal(after.body.data.profile.title, 'Ozon 测试标题'); + assert.equal(after.body.data.variants.length, 2); + assert.equal(after.body.data.variants[0].listing.offer_id, 'm3-variant-a'); + const stale = await request(`/api/products/${first.productId}/editor-draft`, { + method: 'PUT', + headers: headers({ + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': randomUUID(), + 'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`, + }), + body: JSON.stringify(payload), + }); + assert.equal(stale.response.status, 409); + assert.equal(stale.body.error.code, 'VERSION_CONFLICT'); + const removeSecond = { ...payload, variants: [payload.variants[0]] }; + const removed = await request(`/api/products/${first.productId}/editor-draft`, { + method: 'PUT', + headers: headers({ + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': randomUUID(), + 'If-Match': `"${after.body.data.product.revision}:${after.body.data.profile.revision}"`, + }), + body: JSON.stringify(removeSecond), + }); + assert.equal(removed.response.status, 200); + const replacement = { + ...removeSecond, + variants: [ + removeSecond.variants[0], + { + id: null, client_key: 'replacement-variant-b', copy_from_variant_id: existing.id, + display_name: '替代 SKU', source_sku: null, + purchase_cost_cny: '99.00', offer_id: 'm3-variant-b', + }, + ], + }; + const reusedOffer = await request(`/api/products/${first.productId}/editor-draft`, { + method: 'PUT', + headers: headers({ + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': randomUUID(), + 'If-Match': `"${removed.body.data.product_revision}:${removed.body.data.profile_revision}"`, + }), + body: JSON.stringify(replacement), + }); + assert.equal(reusedOffer.response.status, 200); + }); +}); diff --git a/apps/web/src/pages/collection/CollectionPage.tsx b/apps/web/src/pages/collection/CollectionPage.tsx index f758ed3..ded26d2 100644 --- a/apps/web/src/pages/collection/CollectionPage.tsx +++ b/apps/web/src/pages/collection/CollectionPage.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { App as AntdApp, Button, Card, Form, Input, Modal, Progress, Select, Space, Table, Tag, Typography } from 'antd'; import type { TableProps } from 'antd'; import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'; @@ -6,16 +7,18 @@ import type { CollectionItem } from '@atlas/contracts'; import { collectionApi } from '@/services/collection'; import { apiErrorMessage } from '@/services/api'; import { usePlatform } from '@/services/platform'; +import { productEditorApi } from '@/services/productEditor'; const statusLabel: Record = { received: '已接收', materializing: '素材处理中', ready_for_edit: '可编辑', needs_review: '待确认', failed: '采集失败' }; export default function CollectionPage() { - const { message, modal } = AntdApp.useApp(); const { currentPlatform } = usePlatform(); const [items, setItems] = useState([]); const [meta, setMeta] = useState({ active_count: 0, collection_limit: 1000 }); const [loading, setLoading] = useState(false); const [selected, setSelected] = useState([]); const [q, setQ] = useState(''); const [source, setSource] = useState(); const [createOpen, setCreateOpen] = useState(false); const [form] = Form.useForm<{ title?: string; note?: string }>(); + const { message, modal } = AntdApp.useApp(); const { currentPlatform } = usePlatform(); const navigate = useNavigate(); const [items, setItems] = useState([]); const [meta, setMeta] = useState({ active_count: 0, collection_limit: 1000 }); const [loading, setLoading] = useState(false); const [selected, setSelected] = useState([]); const [q, setQ] = useState(''); const [source, setSource] = useState(); const [createOpen, setCreateOpen] = useState(false); const [form] = Form.useForm<{ title?: string; note?: string }>(); const load = useCallback(async () => { if (!currentPlatform) return; setLoading(true); try { const result = await collectionApi.list(currentPlatform.id, { ...(q.trim() ? { q: q.trim() } : {}), ...(source ? { source_platform_code: source as 'OZON' } : {}) }); setItems(result.data); setMeta(result.meta); } catch (error) { message.error(apiErrorMessage(error)); } finally { setLoading(false); } }, [currentPlatform, message, q, source]); useEffect(() => { void load(); }, [load]); - const create = async (values: { title?: string; note?: string }) => { try { await collectionApi.create({ title: values.title?.trim() || undefined, note: values.note?.trim() || null }); message.success('商品草稿已加入采集箱'); setCreateOpen(false); form.resetFields(); await load(); } catch (error) { message.error(apiErrorMessage(error)); } }; + const openEditor = async (itemId: string) => { if (!currentPlatform) return; try { const context = await productEditorApi.enter(itemId, currentPlatform.id); navigate(context.editor_path); } catch (error) { message.error(apiErrorMessage(error)); } }; + const create = async (values: { title?: string; note?: string }) => { try { const created = await collectionApi.create({ title: values.title?.trim() || undefined, note: values.note?.trim() || null }); message.success('商品草稿已加入采集箱'); setCreateOpen(false); form.resetFields(); if (currentPlatform) await openEditor(created.collection_item.id); else await load(); } catch (error) { message.error(apiErrorMessage(error)); } }; const remove = (ids: string[], all = false) => modal.confirm({ title: all ? '清空采集箱' : '删除采集商品', content: all ? `将清空当前 ${meta.active_count} 条采集草稿,不会删除或下架任何平台商品。确定继续吗?` : '仅删除 Atlas 内的采集草稿,不会删除或下架任何平台商品。确定继续吗?', okText: all ? '清空' : '删除', okButtonProps: { danger: true }, onOk: async () => { try { const result = all ? await collectionApi.clear(meta.active_count) : await collectionApi.bulkDelete(ids); message.success(`已删除 ${result.deleted_count} 条采集草稿`); setSelected([]); await load(); } catch (error) { message.error(apiErrorMessage(error)); } } }); const columns: TableProps['columns'] = [ - { title: '产品名称', dataIndex: 'title', render: (value) => value || '未命名商品' }, { title: '来源', dataIndex: 'source', width: 110, render: (value) => {value.kind === 'manual' ? '手动创建' : value.platform_code} }, { title: 'SKU', dataIndex: 'variant_summary', width: 80, render: (value) => value.count }, { title: '采集状态', dataIndex: 'collection_status', width: 120, render: (value) => statusLabel[value] ?? value }, { title: '当前平台上架', dataIndex: 'listing_summary', width: 150, render: (value) => {value.detail} }, { title: '备注', dataIndex: 'note', ellipsis: true, render: (value) => value || '—' }, { title: '更新时间', dataIndex: 'updated_at', width: 170, render: (value) => new Date(value).toLocaleString('zh-CN', { hour12: false }) }, { title: '操作', width: 150, render: (_, item) => }, + { title: '产品名称', dataIndex: 'title', render: (value) => value || '未命名商品' }, { title: '来源', dataIndex: 'source', width: 110, render: (value) => {value.kind === 'manual' ? '手动创建' : value.platform_code} }, { title: 'SKU', dataIndex: 'variant_summary', width: 80, render: (value) => value.count }, { title: '采集状态', dataIndex: 'collection_status', width: 120, render: (value) => statusLabel[value] ?? value }, { title: '当前平台上架', dataIndex: 'listing_summary', width: 150, render: (value) => {value.detail} }, { title: '备注', dataIndex: 'note', ellipsis: true, render: (value) => value || '—' }, { title: '更新时间', dataIndex: 'updated_at', width: 170, render: (value) => new Date(value).toLocaleString('zh-CN', { hour12: false }) }, { title: '操作', width: 150, render: (_, item) => }, ]; return }> `${meta.active_count} / ${meta.collection_limit}`} style={{ maxWidth: 420, marginBottom: 18 }} /> diff --git a/apps/web/src/pages/products/ProductEditPage.tsx b/apps/web/src/pages/products/ProductEditPage.tsx index a053335..5564c57 100644 --- a/apps/web/src/pages/products/ProductEditPage.tsx +++ b/apps/web/src/pages/products/ProductEditPage.tsx @@ -1,16 +1,176 @@ -import { useParams } from 'react-router-dom'; -import PagePlaceholder from '@/components/PagePlaceholder'; +import { useCallback, useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { App as AntdApp, Alert, Button, Card, Descriptions, Divider, Form, Input, Select, Space, Spin, Typography } from 'antd'; +import { DeleteOutlined, PlusOutlined, SaveOutlined } from '@ant-design/icons'; +import type { ProductEditorSnapshot, SaveProductEditorDraftRequest } from '@atlas/contracts'; +import { apiErrorCode, apiErrorMessage } from '@/services/api'; +import { productEditorApi } from '@/services/productEditor'; +import { usePlatform } from '@/services/platform'; -/** 商品 AI 编辑页:编辑 SPU/SKU、媒体、定价与首次发布(业务模块尚未实现) */ +type VariantValues = { + id: string | null; + client_key: string | null; + copy_from_variant_id: string | null; + display_name: string | null; + source_sku: string | null; + purchase_cost_cny: string | null; + offer_id: string | null; +}; +type EditorFormValues = SaveProductEditorDraftRequest; + +function formValues(snapshot: ProductEditorSnapshot): EditorFormValues { + return { + common: snapshot.product.common, + profile: { + target_store_id: snapshot.profile.target_store.id, + fulfillment: snapshot.profile.fulfillment as EditorFormValues['profile']['fulfillment'], + title: snapshot.profile.title, + description: snapshot.profile.description, + tags: snapshot.profile.tags, + }, + variants: snapshot.variants.map((variant) => ({ + id: variant.id, + client_key: null, + copy_from_variant_id: null, + display_name: variant.display_name, + source_sku: variant.source_sku, + purchase_cost_cny: variant.purchase_cost_cny, + offer_id: variant.listing.offer_id, + })), + }; +} + +function nullable(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +function normalizedValues(values: EditorFormValues): EditorFormValues { + return { + ...values, + common: { + source_title: nullable(values.common.source_title), brand: nullable(values.common.brand), + model: nullable(values.common.model), barcode: nullable(values.common.barcode), + purchase_url: nullable(values.common.purchase_url), supply_note: nullable(values.common.supply_note), + }, + profile: { + ...values.profile, title: nullable(values.profile.title), description: nullable(values.profile.description), + tags: values.profile.tags.map((tag) => tag.trim()).filter(Boolean), + }, + variants: values.variants.map((variant) => ({ + ...variant, display_name: nullable(variant.display_name), source_sku: nullable(variant.source_sku), + purchase_cost_cny: nullable(variant.purchase_cost_cny), offer_id: nullable(variant.offer_id), + })), + }; +} + +/** M3.1 正式编辑入口:产品、当前平台文案与 SKU 基础数据一次性原子保存。 */ const ProductEditPage = () => { - const { id } = useParams<{ id: string }>(); + const { id: productId } = useParams<{ id: string }>(); + const { currentPlatform } = usePlatform(); + const { message } = AntdApp.useApp(); + const navigate = useNavigate(); + const [form] = Form.useForm(); + const [snapshot, setSnapshot] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); - return ( - - ); + const load = useCallback(async () => { + if (!productId || !currentPlatform) return; + setLoading(true); + try { + const next = await productEditorApi.read(productId, currentPlatform.id); + setSnapshot(next); + form.setFieldsValue(formValues(next)); + } catch (error) { + message.error(apiErrorMessage(error)); + } finally { + setLoading(false); + } + }, [currentPlatform, form, message, productId]); + + useEffect(() => { void load(); }, [load]); + + const save = async (values: EditorFormValues) => { + if (!productId || !currentPlatform || !snapshot) return; + setSaving(true); + try { + await productEditorApi.save(productId, currentPlatform.id, { + product: snapshot.product.revision, + profile: snapshot.profile.revision, + }, normalizedValues(values)); + message.success('草稿已保存'); + await load(); + } catch (error) { + if (apiErrorCode(error) === 'VERSION_CONFLICT') { + message.warning('商品已在其他位置更新,已重新加载最新内容'); + await load(); + } else { + message.error(apiErrorMessage(error)); + } + } finally { + setSaving(false); + } + }; + + if (!currentPlatform) return ; + if (loading && !snapshot) return ; + if (!snapshot) return void load()}>重试} />; + + return
+ + + +
商品编辑当前平台:{currentPlatform.name} · 草稿保存不会发布商品
+ +
+
+ {snapshot.warnings.map((warning) => )} + + + + + + + + + + + + + {snapshot.profile.target_store.name}({snapshot.profile.target_store.currency_code}) + {snapshot.profile.market_code ?? '未配置'} + + + + + + + + + 保存草稿不提交 Ozon}> + + {(fields, { add, remove }) => + {fields.map((field, index) => 1 ? : null}> + + + + + + + + + + )} + + } + + + + +
+
; }; export default ProductEditPage; diff --git a/apps/web/src/router/index.tsx b/apps/web/src/router/index.tsx index 9df14e1..525dde9 100644 --- a/apps/web/src/router/index.tsx +++ b/apps/web/src/router/index.tsx @@ -63,6 +63,7 @@ export const router = createBrowserRouter([ { path: 'pricing', element: }, // 商品 AI 编辑页:从采集箱/在线商品进入,不在左侧菜单中出现 { path: 'products/:id', element: }, + { path: 'products/:id/editor', element: }, // 个人中心设置页:从页头用户菜单进入,不在左侧菜单中出现 { path: 'settings/profile', element: }, { path: 'settings/password', element: }, diff --git a/apps/web/src/services/productEditor.ts b/apps/web/src/services/productEditor.ts new file mode 100644 index 0000000..0a76236 --- /dev/null +++ b/apps/web/src/services/productEditor.ts @@ -0,0 +1,30 @@ +import type { + ProductEditorContext, + ProductEditorSnapshot, + SaveProductEditorDraftRequest, + SaveProductEditorDraftResponse, +} from '@atlas/contracts'; +import { api } from './api'; + +export const productEditorApi = { + enter: (collectionItemId: string, platformId: string) => + api.post(`/collection-items/${collectionItemId}/editor-contexts`, undefined, { + headers: { 'X-Atlas-Platform-Id': platformId, 'Idempotency-Key': crypto.randomUUID() }, + }), + read: (productId: string, platformId: string) => + api.get(`/products/${productId}/editor`, { + headers: { 'X-Atlas-Platform-Id': platformId }, + }), + save: ( + productId: string, + platformId: string, + revisions: { product: number; profile: number }, + payload: SaveProductEditorDraftRequest, + ) => api.put(`/products/${productId}/editor-draft`, payload, { + headers: { + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': crypto.randomUUID(), + 'If-Match': `"${revisions.product}:${revisions.profile}"`, + }, + }), +}; diff --git a/docs/api/README.md b/docs/api/README.md index 24a5b50..bd01183 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -16,7 +16,7 @@ API 启动后会在 `/api-docs` 提供 Swagger UI,并在其 OpenAPI 文档中 | [`watermarks-and-assets.md`](./watermarks-and-assets.md) | 部分已实现 | 水印模板、字体、店铺推荐水印,以及水印图片直传、校验和短时预览;图片渲染派生待实现。 | | [`pricing.md`](./pricing.md) | 已设计 | 汇率、物流价卡、即时试算、定价方案、报价快照衔接。 | | [`collection-and-extension.md`](./collection-and-extension.md) | 已设计 | 插件授权、采集写入、采集箱查询、素材转存与状态。 | -| [`product-editor-and-publishing.md`](./product-editor-and-publishing.md) | 已设计 | 商品编辑、SKU、类目、属性、AI、媒体选择、定价确认与部分 SKU 首次上架。 | +| [`product-editor-and-publishing.md`](./product-editor-and-publishing.md) | M3.1 部分已实现 | 编辑上下文、只读快照和产品/平台文案/SKU 的原子草稿保存;类目/属性、媒体、定价、AI 与首次上架仍待实现。 | | [`online-listings.md`](./online-listings.md) | 初版已设计 | 店铺独有的在线商品镜像、读取与异步同步;编辑/认领仍待讨论。 | 旧的 [`../product/api.md`](../product/api.md) 是商品域的早期草案:已确认的产品规则可作为背景,但不能当作最终 HTTP 契约。后续商品模块会迁入本目录并替换其中的路径和细节。 diff --git a/docs/api/product-editor-and-publishing.md b/docs/api/product-editor-and-publishing.md index 4c3eb35..0c8cacd 100644 --- a/docs/api/product-editor-and-publishing.md +++ b/docs/api/product-editor-and-publishing.md @@ -1,5 +1,7 @@ # 商品编辑与首次上架 API +> M3.0 已实现第 1 节的 `POST /editor-contexts` 和 `GET /editor` 基础读模型,以及对应数据库归属约束。M3.1 已实现第 2 节的产品基础字段、当前平台文案、SKU 与 `offer_id` 的原子草稿保存及双版本并发控制;规格、类目/属性切换、媒体选择、定价、AI 与发布仍是冻结契约,尚未实现。 + > 此处的商品始终是采集箱内的 Atlas 草稿。所有平台范围接口要求 `X-Atlas-Platform-Id`。商品通用资料可以跨平台复用;`product_platform_profile`、SKU 首次导入状态和在线商品不能跨平台/门店复用。 ## 1. 进入编辑与读取编辑器 diff --git a/docs/project/HANDOFF.md b/docs/project/HANDOFF.md index 6d98260..9ac308a 100644 --- a/docs/project/HANDOFF.md +++ b/docs/project/HANDOFF.md @@ -161,6 +161,21 @@ - 1688、淘宝、天猫已加入受限 MAIN-world 商品状态桥:1688 仅投影公开商品的画廊、SKU、价格、参数等固定字段;淘宝/天猫仅投影 `__ICE_APP_CONTEXT__` 中的公开商品结果。桥不提供任意 window 读取、Cookie、请求头或通用 fetch。新采集器将这些结构化数据、JSON-LD、受限详情 DOM 与既有图片 profile 映射为 Atlas 新 `CollectedProductPayload`。 - `/collection-test/{collectionItemId}` 现单独展示商品描述、类目路径、全部特征表、规格轴、SKU 草稿、素材候选,并保留规范化载荷和原始证据,供解析核对。扩展解析器测试覆盖 JSON-LD 描述兜底、完整特征合并和图片白名单。 +### 1.19 开发进度(2026-09-09):M3.0 商品编辑领域基础 + +- 新增迁移 `018_product_editor_foundation.sql`:采集期最小 `products` / `product_variants` 扩展为可编辑的产品聚合,并建立平台发布配置、SKU 平台 listing、平台属性及未映射属性草稿表。所有表启用并强制工作空间 RLS。 +- `product_variant_platform_listings` 同时保存 `product_id`,以 profile→product 和 variant→product 两条复合外键强制同商品归属;数据库会拒绝同一工作空间中“商品 A profile + 商品 B SKU”的错误组合。属性和未映射属性也受 profile/variant 与 product 的复合外键约束。 +- 已实现 `POST /api/collection-items/{id}/editor-contexts`:只有当前页头目标平台存在已验证默认门店时,才原子取得或创建产品的该平台 profile,并为现有 SKU 建立初始 `not_listed` listing。`GET /api/products/{id}/editor` 返回产品、profile、SKU listing、属性和待处理未映射属性的只读快照。 +- `products.version` 已迁移为统一命名的 `revision`;新增共享版本冲突辅助。M3.1 的完整草稿写入仍必须以 `product.revision + profile.revision` 条件更新为边界,并覆盖并发保存。 +- API 集成测试覆盖默认店铺前置、上下文幂等复用、读模型、跨商品外键拒绝和未映射属性读回。正式编辑 Web、草稿保存、类目切换、素材选择、定价与发布尚未开始。 + +### 1.20 开发进度(2026-09-09):M3.1 商品草稿保存与正式编辑入口 + +- 已实现 `PUT /api/products/{id}/editor-draft`:产品基础资料、当前平台标题/描述/标签/履约方式,以及完整 SKU 集合和未上架 listing 的 `offer_id` 在同一事务保存。新 SKU 以 `client_key` 回传稳定 ID;省略现有 SKU 会软删除 Atlas 草稿,绝不触发平台下架。 +- 请求必须携带 `If-Match: "{product_revision}:{profile_revision}"` 与 `Idempotency-Key`。服务端先后执行两个带 revision 条件的更新;任一步冲突或 SKU/货号校验失败都会回滚整笔保存。店铺内货号重复会返回可展示的 `422`,重复请求键安全重放原响应。 +- Web 的采集箱“编辑上架”和手动创建现会创建编辑上下文后进入 `/products/{id}/editor`。正式页可编辑产品基础资料、当前平台文案及 SKU/货号,遇到版本冲突会重新加载最新快照。 +- 此批刻意不展示未具备平台 schema 或发布校验的数据:类目/属性切换与未映射属性恢复、规格继承、候选媒体物化/选图、定价确认、AI 和首次 Ozon 导入仍待后续纵向任务。 + ## 2. 已确认产品决策 | 主题 | 决策 | diff --git a/docs/reviews/implementation-readiness-open-items.md b/docs/reviews/implementation-readiness-open-items.md index ec62ddc..3a1e11e 100644 --- a/docs/reviews/implementation-readiness-open-items.md +++ b/docs/reviews/implementation-readiness-open-items.md @@ -37,7 +37,7 @@ ### 1. SKU 与平台发布配置的同商品归属 -**状态:已决策,待数据库约束设计。** +**状态:M3.0 已落地数据库约束(2026-09-09);发布任务、在线镜像等后续表必须复用该归属链。** `product_variant_platform_listings` 同时引用平台发布配置(profile)和通用 SKU(variant),但必须强制:`variant.product_id = profile.product_id`。同一条归属链也适用于属性、报价、发布任务项和在线镜像映射。 @@ -45,7 +45,7 @@ ### 2. 类目切换后未映射属性的持久化 -**状态:已决策,待数据模型补齐。** +**状态:M3.0 已落地持久化表(2026-09-09);类目切换写入与恢复操作随编辑草稿保存实现。** 切换目标平台类目后,旧类目中无法映射的属性值必须作为 Atlas 草稿保留,供用户查看、复用或手动处理,不能只出现在一次 API response 的 warning 中。 @@ -58,7 +58,7 @@ ### 3. 乐观锁的统一实施 -**状态:已决策,待 Repository 规则和测试落地。** +**状态:M3.1 已使用产品/profile 双 `revision` 条件更新、事务回滚和集成测试落地;后续编辑资源不得绕过该规则。** `revision` 不依赖数据库触发器自动递增。所有可编辑资源的更新必须由统一 Repository/数据访问层执行: diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index e370ec2..f01a45a 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -3,3 +3,4 @@ export * from './auth'; export * from './stores'; export * from './watermarks'; export * from './media'; +export * from './product-editor'; diff --git a/packages/contracts/src/product-editor.ts b/packages/contracts/src/product-editor.ts new file mode 100644 index 0000000..70f9fd6 --- /dev/null +++ b/packages/contracts/src/product-editor.ts @@ -0,0 +1,127 @@ +import { z } from 'zod'; + +/** + * M3.0 editor entry and read-model contracts. Draft mutation, pricing, and + * publishing DTOs intentionally stay out of this first editor slice. + */ +export type ProductEditorContext = { + collection_item_id: string; + product_id: string; + profile_id: string; + target_store: { id: string; name: string; currency_code: string }; + editor_path: string; +}; + +export type ProductEditorSnapshot = { + product: { + id: string; + revision: number; + common: { + source_title: string | null; + brand: string | null; + model: string | null; + barcode: string | null; + purchase_url: string | null; + supply_note: string | null; + }; + }; + profile: { + id: string; + revision: number; + platform_id: string; + market_code: string | null; + fulfillment: string; + target_store: { id: string; name: string; currency_code: string }; + title: string | null; + description: string | null; + tags: string[]; + category: { + id: string | null; + platform_type_id: string | null; + source: string | null; + }; + }; + variants: Array<{ + id: string; + source_variant_key: string | null; + source_sku: string | null; + display_name: string | null; + sort_order: number; + purchase_cost_cny: string | null; + listing: { + id: string; + offer_id: string | null; + status: string; + selling_price: string | null; + list_price: string | null; + currency_code: string | null; + }; + }>; + attributes: Array<{ + id: string; + scope: 'common' | 'variant'; + variant_id: string | null; + platform_attribute_id: string; + complex_id: string; + values: unknown; + language_code: string | null; + source: string; + confirmation_status: string; + }>; + unmapped_attributes: Array<{ + id: string; + variant_id: string | null; + original_platform_category_id: string | null; + original_platform_attribute_id: string; + original_complex_id: string; + original_name: string; + values: unknown; + language_code: string | null; + reason: string; + status: string; + }>; + warnings: Array<{ code: string; message: string }>; +}; + +const uuid = z.uuid(); +const nullableText = (max: number) => z.string().trim().max(max).nullable(); + +/** M3.1 intentionally covers editable product/profile/SKU fundamentals. */ +export const saveProductEditorDraftRequestSchema = z.object({ + common: z.object({ + source_title: nullableText(1_000), + brand: nullableText(300), + model: nullableText(300), + barcode: nullableText(300), + purchase_url: z.url().nullable(), + supply_note: nullableText(4_000), + }).strict(), + profile: z.object({ + target_store_id: uuid, + fulfillment: z.enum(['rfbs', 'fbs', 'fbo', 'fbp', 'unknown']), + title: nullableText(1_000), + description: nullableText(100_000), + tags: z.array(z.string().trim().min(1).max(100)).max(30), + }).strict(), + variants: z.array(z.object({ + id: uuid.nullable(), + client_key: z.string().trim().min(1).max(100).nullable(), + copy_from_variant_id: uuid.nullable().optional(), + display_name: nullableText(1_000), + source_sku: nullableText(500), + purchase_cost_cny: z.string().regex(/^\d+(\.\d{1,4})?$/).nullable(), + offer_id: nullableText(500), + }).strict().superRefine((variant, context) => { + if (!variant.id && !variant.client_key) { + context.addIssue({ code: 'custom', path: ['client_key'], message: '新 SKU 必须提供 client_key' }); + } + })).min(1).max(100), +}).strict(); + +export type SaveProductEditorDraftRequest = z.infer; +export type SaveProductEditorDraftResponse = { + product_revision: number; + profile_revision: number; + created_variants: Array<{ client_key: string; id: string }>; + warnings: Array<{ code: string; message: string }>; +}; diff --git a/packages/database/migrations/018_product_editor_foundation.sql b/packages/database/migrations/018_product_editor_foundation.sql new file mode 100644 index 0000000..6c46dc3 --- /dev/null +++ b/packages/database/migrations/018_product_editor_foundation.sql @@ -0,0 +1,186 @@ +SET search_path TO atlas, public; + +-- M3.0 turns the collection-time draft into an editable product aggregate. +-- Keep this migration deliberately narrower than publishing, pricing, AI, and +-- online-sync models: those capabilities need their own immutable snapshots. + +ALTER TABLE products RENAME COLUMN version TO revision; +ALTER TABLE products + ADD COLUMN brand TEXT, + ADD COLUMN model TEXT, + ADD COLUMN barcode TEXT, + ADD COLUMN purchase_url TEXT, + ADD COLUMN supply_note TEXT, + ADD COLUMN default_physical_length_mm NUMERIC(12,3) CHECK (default_physical_length_mm IS NULL OR default_physical_length_mm > 0), + ADD COLUMN default_physical_width_mm NUMERIC(12,3) CHECK (default_physical_width_mm IS NULL OR default_physical_width_mm > 0), + ADD COLUMN default_physical_height_mm NUMERIC(12,3) CHECK (default_physical_height_mm IS NULL OR default_physical_height_mm > 0), + ADD COLUMN default_net_weight_g NUMERIC(12,3) CHECK (default_net_weight_g IS NULL OR default_net_weight_g > 0), + ADD COLUMN default_package_length_mm NUMERIC(12,3) CHECK (default_package_length_mm IS NULL OR default_package_length_mm > 0), + ADD COLUMN default_package_width_mm NUMERIC(12,3) CHECK (default_package_width_mm IS NULL OR default_package_width_mm > 0), + ADD COLUMN default_package_height_mm NUMERIC(12,3) CHECK (default_package_height_mm IS NULL OR default_package_height_mm > 0), + ADD COLUMN default_shipping_weight_g NUMERIC(12,3) CHECK (default_shipping_weight_g IS NULL OR default_shipping_weight_g > 0), + ADD COLUMN physical_spec_status TEXT NOT NULL DEFAULT 'missing' CHECK (physical_spec_status IN ('missing', 'ambiguous', 'confirmed')), + ADD COLUMN package_spec_status TEXT NOT NULL DEFAULT 'missing' CHECK (package_spec_status IN ('missing', 'ambiguous', 'confirmed')); + +ALTER TABLE product_variants + ADD COLUMN purchase_cost_cny NUMERIC(18,4) CHECK (purchase_cost_cny IS NULL OR purchase_cost_cny >= 0), + ADD COLUMN specification_values JSONB NOT NULL DEFAULT '[]'::jsonb, + ADD COLUMN physical_length_mm NUMERIC(12,3) CHECK (physical_length_mm IS NULL OR physical_length_mm > 0), + ADD COLUMN physical_width_mm NUMERIC(12,3) CHECK (physical_width_mm IS NULL OR physical_width_mm > 0), + ADD COLUMN physical_height_mm NUMERIC(12,3) CHECK (physical_height_mm IS NULL OR physical_height_mm > 0), + ADD COLUMN net_weight_g NUMERIC(12,3) CHECK (net_weight_g IS NULL OR net_weight_g > 0), + ADD COLUMN package_length_mm NUMERIC(12,3) CHECK (package_length_mm IS NULL OR package_length_mm > 0), + ADD COLUMN package_width_mm NUMERIC(12,3) CHECK (package_width_mm IS NULL OR package_width_mm > 0), + ADD COLUMN package_height_mm NUMERIC(12,3) CHECK (package_height_mm IS NULL OR package_height_mm > 0), + ADD COLUMN shipping_weight_g NUMERIC(12,3) CHECK (shipping_weight_g IS NULL OR shipping_weight_g > 0), + ADD COLUMN physical_spec_status TEXT CHECK (physical_spec_status IS NULL OR physical_spec_status IN ('missing', 'ambiguous', 'confirmed')), + ADD COLUMN package_spec_status TEXT CHECK (package_spec_status IS NULL OR package_spec_status IN ('missing', 'ambiguous', 'confirmed')); + +CREATE UNIQUE INDEX ux_product_variants_product_identity + ON product_variants (workspace_id, id, product_id); +CREATE UNIQUE INDEX ux_product_variants_source_key + ON product_variants (workspace_id, product_id, source_variant_key) + WHERE source_variant_key IS NOT NULL; + +CREATE TABLE product_platform_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL, + product_id UUID NOT NULL, + platform_id UUID NOT NULL, + target_store_id UUID NOT NULL, + market_code TEXT, + fulfillment TEXT NOT NULL DEFAULT 'unknown' CHECK (fulfillment IN ('rfbs', 'fbs', 'fbo', 'fbp', 'unknown')), + title TEXT, + description TEXT, + tags JSONB NOT NULL DEFAULT '[]'::jsonb, + platform_product_category_id UUID, + platform_type_id TEXT, + category_source TEXT CHECK (category_source IN ('source_capture', 'ai_suggestion', 'manual')), + revision BIGINT NOT NULL DEFAULT 1 CHECK (revision > 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (workspace_id, product_id) + REFERENCES products(workspace_id, id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, target_store_id, platform_id) + REFERENCES stores(workspace_id, id, platform_id) ON DELETE RESTRICT, + FOREIGN KEY (platform_id, platform_product_category_id) + REFERENCES platform_product_categories(platform_id, id) ON DELETE RESTRICT, + UNIQUE (workspace_id, id), + UNIQUE (workspace_id, id, product_id), + UNIQUE (workspace_id, product_id, platform_id), + UNIQUE (workspace_id, id, product_id, platform_id) +); +CREATE INDEX ix_product_platform_profiles_store + ON product_platform_profiles (workspace_id, target_store_id, updated_at DESC); + +-- product_id is intentionally repeated on this relation. The two composite +-- foreign keys make it impossible to join a profile for product A to a SKU of +-- product B, even when both records are otherwise valid in one workspace. +CREATE TABLE product_variant_platform_listings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL, + product_id UUID NOT NULL, + profile_id UUID NOT NULL, + platform_id UUID NOT NULL, + product_variant_id UUID NOT NULL, + store_id UUID NOT NULL, + offer_id TEXT, + currency_code CHAR(3) REFERENCES currencies(code), + selling_price NUMERIC(18,4) CHECK (selling_price IS NULL OR selling_price >= 0), + list_price NUMERIC(18,4) CHECK (list_price IS NULL OR list_price >= 0), + status TEXT NOT NULL DEFAULT 'not_listed' CHECK (status IN ('not_listed', 'submitting', 'imported', 'failed')), + platform_product_id TEXT, + first_imported_at TIMESTAMPTZ, + last_error JSONB, + revision BIGINT NOT NULL DEFAULT 1 CHECK (revision > 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (workspace_id, profile_id, product_id, platform_id) + REFERENCES product_platform_profiles(workspace_id, id, product_id, platform_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, product_variant_id, product_id) + REFERENCES product_variants(workspace_id, id, product_id) ON DELETE RESTRICT, + FOREIGN KEY (workspace_id, store_id, platform_id) + REFERENCES stores(workspace_id, id, platform_id) ON DELETE RESTRICT, + UNIQUE (workspace_id, id), + UNIQUE (workspace_id, profile_id, product_variant_id), + CHECK (list_price IS NULL OR selling_price IS NULL OR list_price >= selling_price), + CHECK ((status <> 'imported') OR (first_imported_at IS NOT NULL AND platform_product_id IS NOT NULL)) +); +CREATE UNIQUE INDEX ux_variant_platform_listings_store_offer + ON product_variant_platform_listings (workspace_id, store_id, platform_id, offer_id) + WHERE offer_id IS NOT NULL; +CREATE INDEX ix_variant_platform_listings_profile_status + ON product_variant_platform_listings (workspace_id, profile_id, status); + +CREATE TABLE product_attributes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL, + product_id UUID NOT NULL, + profile_id UUID NOT NULL, + product_variant_id UUID, + scope TEXT NOT NULL CHECK (scope IN ('common', 'variant')), + platform_attribute_id TEXT NOT NULL, + complex_id TEXT NOT NULL DEFAULT '0', + values JSONB NOT NULL DEFAULT '[]'::jsonb, + language_code TEXT, + source TEXT NOT NULL DEFAULT 'manual', + confirmation_status TEXT NOT NULL DEFAULT 'confirmed' CHECK (confirmation_status IN ('missing', 'ambiguous', 'confirmed')), + raw_value TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (workspace_id, profile_id, product_id) + REFERENCES product_platform_profiles(workspace_id, id, product_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, product_variant_id, product_id) + REFERENCES product_variants(workspace_id, id, product_id) ON DELETE CASCADE, + CHECK ((scope = 'common' AND product_variant_id IS NULL) OR (scope = 'variant' AND product_variant_id IS NOT NULL)) +); +CREATE UNIQUE INDEX ux_product_attributes_common + ON product_attributes (workspace_id, profile_id, platform_attribute_id, complex_id, COALESCE(language_code, '')) + WHERE scope = 'common'; +CREATE UNIQUE INDEX ux_product_attributes_variant + ON product_attributes (workspace_id, profile_id, product_variant_id, platform_attribute_id, complex_id, COALESCE(language_code, '')) + WHERE scope = 'variant'; + +-- A category change may remove an attribute from the active platform schema. +-- Keep the user's value as a first-class draft rather than returning a warning +-- that disappears on the next editor read. +CREATE TABLE product_unmapped_attributes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL, + product_id UUID NOT NULL, + profile_id UUID NOT NULL, + platform_id UUID NOT NULL, + product_variant_id UUID, + original_platform_category_id UUID, + original_platform_attribute_id TEXT NOT NULL, + original_complex_id TEXT NOT NULL DEFAULT '0', + original_name TEXT NOT NULL, + values JSONB NOT NULL DEFAULT '[]'::jsonb, + language_code TEXT, + reason TEXT NOT NULL CHECK (reason IN ('category_changed', 'attribute_unavailable')), + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'restored', 'discarded')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + resolved_at TIMESTAMPTZ, + FOREIGN KEY (workspace_id, profile_id, product_id, platform_id) + REFERENCES product_platform_profiles(workspace_id, id, product_id, platform_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, product_variant_id, product_id) + REFERENCES product_variants(workspace_id, id, product_id) ON DELETE CASCADE, + FOREIGN KEY (platform_id, original_platform_category_id) + REFERENCES platform_product_categories(platform_id, id) ON DELETE SET NULL, + CHECK (resolved_at IS NULL OR resolved_at >= created_at) +); + +DO $$ +DECLARE table_name TEXT; +BEGIN + FOREACH table_name IN ARRAY ARRAY[ + 'product_platform_profiles', + 'product_variant_platform_listings', + 'product_attributes', + 'product_unmapped_attributes' + ] LOOP + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', table_name); + EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', table_name); + EXECUTE format('CREATE POLICY workspace_isolation ON %I USING (workspace_id = app_current_workspace_id()) WITH CHECK (workspace_id = app_current_workspace_id())', table_name); + END LOOP; +END $$;