feat(product-edit):商品编辑页初版
This commit is contained in:
@@ -11,6 +11,7 @@ import { WatermarksModule } from './watermarks/watermarks.module.js';
|
|||||||
import { MediaModule } from './media/media.module.js';
|
import { MediaModule } from './media/media.module.js';
|
||||||
import { CollectionModule } from './collection/collection.module.js';
|
import { CollectionModule } from './collection/collection.module.js';
|
||||||
import { ExtensionModule } from './extension/extension.module.js';
|
import { ExtensionModule } from './extension/extension.module.js';
|
||||||
|
import { ProductEditorModule } from './product-editor/product-editor.module.js';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
class HealthController {
|
class HealthController {
|
||||||
@@ -49,6 +50,7 @@ class HealthController {
|
|||||||
MediaModule,
|
MediaModule,
|
||||||
CollectionModule,
|
CollectionModule,
|
||||||
ExtensionModule,
|
ExtensionModule,
|
||||||
|
ProductEditorModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { CreateCollectionItemRequest, ListCollectionItemsQuery, Materialize
|
|||||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||||
import { ApiError } from '../common/api-error.js';
|
import { ApiError } from '../common/api-error.js';
|
||||||
import { IdempotencyService } from '../common/idempotency.service.js';
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
|
import { throwRevisionConflict } from '../common/revision.js';
|
||||||
import { DATABASE } from '../database/database.module.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 };
|
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 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 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 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 })) };
|
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) {
|
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) => {
|
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]);
|
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) } };
|
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 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 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<Row>(`${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 requireItem(tx: DatabaseTransaction, id: string) { const row = await tx.query<Row>(`${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 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';
|
const collectionGroupBy = 'GROUP BY c.id, product.id, p.code';
|
||||||
|
|||||||
@@ -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<never> {
|
||||||
|
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}状态已变化`);
|
||||||
|
}
|
||||||
@@ -223,7 +223,7 @@ export class ExtensionService {
|
|||||||
if (!Number.isSafeInteger(snapshotRevision) || snapshotRevision < 1)
|
if (!Number.isSafeInteger(snapshotRevision) || snapshotRevision < 1)
|
||||||
throw new Error('采集来源快照版本更新失败');
|
throw new Error('采集来源快照版本更新失败');
|
||||||
await tx.query(
|
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],
|
[payload.common.title?.value ?? null, productId],
|
||||||
);
|
);
|
||||||
// The historical task rows must remain auditable after replacing the
|
// The historical task rows must remain auditable after replacing the
|
||||||
|
|||||||
@@ -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]) };
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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<ProductEditorSnapshot> {
|
||||||
|
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<never> {
|
||||||
|
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<never> {
|
||||||
|
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<ProductRow>(
|
||||||
|
`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<ProductRow>(
|
||||||
|
`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<ProfileRow>(`${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<ProfileRow>(`${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`;
|
||||||
@@ -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<string, string> = {}) {
|
||||||
|
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>(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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
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 { App as AntdApp, Button, Card, Form, Input, Modal, Progress, Select, Space, Table, Tag, Typography } from 'antd';
|
||||||
import type { TableProps } from 'antd';
|
import type { TableProps } from 'antd';
|
||||||
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
@@ -6,16 +7,18 @@ import type { CollectionItem } from '@atlas/contracts';
|
|||||||
import { collectionApi } from '@/services/collection';
|
import { collectionApi } from '@/services/collection';
|
||||||
import { apiErrorMessage } from '@/services/api';
|
import { apiErrorMessage } from '@/services/api';
|
||||||
import { usePlatform } from '@/services/platform';
|
import { usePlatform } from '@/services/platform';
|
||||||
|
import { productEditorApi } from '@/services/productEditor';
|
||||||
|
|
||||||
const statusLabel: Record<string, string> = { received: '已接收', materializing: '素材处理中', ready_for_edit: '可编辑', needs_review: '待确认', failed: '采集失败' };
|
const statusLabel: Record<string, string> = { received: '已接收', materializing: '素材处理中', ready_for_edit: '可编辑', needs_review: '待确认', failed: '采集失败' };
|
||||||
export default function CollectionPage() {
|
export default function CollectionPage() {
|
||||||
const { message, modal } = AntdApp.useApp(); const { currentPlatform } = usePlatform(); const [items, setItems] = useState<CollectionItem[]>([]); const [meta, setMeta] = useState({ active_count: 0, collection_limit: 1000 }); const [loading, setLoading] = useState(false); const [selected, setSelected] = useState<string[]>([]); const [q, setQ] = useState(''); const [source, setSource] = useState<string>(); 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<CollectionItem[]>([]); const [meta, setMeta] = useState({ active_count: 0, collection_limit: 1000 }); const [loading, setLoading] = useState(false); const [selected, setSelected] = useState<string[]>([]); const [q, setQ] = useState(''); const [source, setSource] = useState<string>(); 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]);
|
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]);
|
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 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<CollectionItem>['columns'] = [
|
const columns: TableProps<CollectionItem>['columns'] = [
|
||||||
{ title: '产品名称', dataIndex: 'title', render: (value) => value || '未命名商品' }, { title: '来源', dataIndex: 'source', width: 110, render: (value) => <Tag>{value.kind === 'manual' ? '手动创建' : value.platform_code}</Tag> }, { 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) => <Typography.Text type="secondary">{value.detail}</Typography.Text> }, { 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) => <Space><Button type="link" size="small" onClick={() => message.info('商品编辑将在下一任务提供')}>编辑上架</Button><Button type="link" danger size="small" onClick={() => remove([item.id])}>删除</Button></Space> },
|
{ title: '产品名称', dataIndex: 'title', render: (value) => value || '未命名商品' }, { title: '来源', dataIndex: 'source', width: 110, render: (value) => <Tag>{value.kind === 'manual' ? '手动创建' : value.platform_code}</Tag> }, { 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) => <Typography.Text type="secondary">{value.detail}</Typography.Text> }, { 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) => <Space><Button type="link" size="small" onClick={() => void openEditor(item.id)}>编辑上架</Button><Button type="link" danger size="small" onClick={() => remove([item.id])}>删除</Button></Space> },
|
||||||
];
|
];
|
||||||
return <Card title="采集箱" extra={<Space><Button icon={<ReloadOutlined />} onClick={() => void load()} /><Button danger disabled={!items.length} onClick={() => remove([], true)}>清空采集箱</Button><Button danger icon={<DeleteOutlined />} disabled={!selected.length} onClick={() => remove(selected)}>批量删除</Button><Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>手动创建商品</Button></Space>}>
|
return <Card title="采集箱" extra={<Space><Button icon={<ReloadOutlined />} onClick={() => void load()} /><Button danger disabled={!items.length} onClick={() => remove([], true)}>清空采集箱</Button><Button danger icon={<DeleteOutlined />} disabled={!selected.length} onClick={() => remove(selected)}>批量删除</Button><Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>手动创建商品</Button></Space>}>
|
||||||
<Progress percent={meta.collection_limit ? Math.round(meta.active_count / meta.collection_limit * 100) : 0} format={() => `${meta.active_count} / ${meta.collection_limit}`} style={{ maxWidth: 420, marginBottom: 18 }} />
|
<Progress percent={meta.collection_limit ? Math.round(meta.active_count / meta.collection_limit * 100) : 0} format={() => `${meta.active_count} / ${meta.collection_limit}`} style={{ maxWidth: 420, marginBottom: 18 }} />
|
||||||
|
|||||||
@@ -1,16 +1,176 @@
|
|||||||
import { useParams } from 'react-router-dom';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import PagePlaceholder from '@/components/PagePlaceholder';
|
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 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<EditorFormValues>();
|
||||||
|
const [snapshot, setSnapshot] = useState<ProductEditorSnapshot | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
return (
|
const load = useCallback(async () => {
|
||||||
<PagePlaceholder
|
if (!productId || !currentPlatform) return;
|
||||||
title={`商品编辑(${id ?? '未知 ID'})`}
|
setLoading(true);
|
||||||
description="商品 AI 编辑页:SPU/SKU、媒体、定价与发布,模块开发中。"
|
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 <Alert type="warning" message="正在读取平台上下文" />;
|
||||||
|
if (loading && !snapshot) return <Spin size="large" style={{ display: 'block', margin: '72px auto' }} />;
|
||||||
|
if (!snapshot) return <Alert type="error" message="无法读取商品编辑数据" action={<Button onClick={() => void load()}>重试</Button>} />;
|
||||||
|
|
||||||
|
return <Form form={form} layout="vertical" onFinish={save} requiredMark={false}>
|
||||||
|
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||||
|
<Card>
|
||||||
|
<Space style={{ width: '100%', justifyContent: 'space-between' }} align="start">
|
||||||
|
<div><Typography.Title level={3} style={{ margin: 0 }}>商品编辑</Typography.Title><Typography.Text type="secondary">当前平台:{currentPlatform.name} · 草稿保存不会发布商品</Typography.Text></div>
|
||||||
|
<Space><Button onClick={() => navigate('/collection')}>返回采集箱</Button><Button type="primary" htmlType="submit" loading={saving} icon={<SaveOutlined />}>保存草稿</Button></Space>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
{snapshot.warnings.map((warning) => <Alert key={warning.code} type="warning" message={warning.message} showIcon />)}
|
||||||
|
<Card title="商品基础信息">
|
||||||
|
<Form.Item name={['common', 'source_title']} label="商品名称" rules={[{ max: 1000 }]}><Input placeholder="商品名称" /></Form.Item>
|
||||||
|
<Space style={{ width: '100%' }} size={16} align="start">
|
||||||
|
<Form.Item name={['common', 'brand']} label="品牌" style={{ flex: 1 }}><Input /></Form.Item>
|
||||||
|
<Form.Item name={['common', 'model']} label="型号" style={{ flex: 1 }}><Input /></Form.Item>
|
||||||
|
<Form.Item name={['common', 'barcode']} label="条形码" style={{ flex: 1 }}><Input /></Form.Item>
|
||||||
|
</Space>
|
||||||
|
<Form.Item name={['common', 'purchase_url']} label="采购链接" rules={[{ type: 'url', message: '请输入有效 URL' }]}><Input /></Form.Item>
|
||||||
|
<Form.Item name={['common', 'supply_note']} label="货源备注"><Input.TextArea rows={2} maxLength={4000} /></Form.Item>
|
||||||
|
</Card>
|
||||||
|
<Card title="当前平台资料">
|
||||||
|
<Descriptions size="small" column={2} style={{ marginBottom: 18 }}>
|
||||||
|
<Descriptions.Item label="目标店铺">{snapshot.profile.target_store.name}({snapshot.profile.target_store.currency_code})</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="市场">{snapshot.profile.market_code ?? '未配置'}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
<Space style={{ width: '100%' }} size={16} align="start">
|
||||||
|
<Form.Item name={['profile', 'fulfillment']} label="履约方式" style={{ width: 180 }}><Select options={[{ value: 'unknown', label: '待确认' }, { value: 'rfbs', label: 'rFBS' }, { value: 'fbs', label: 'FBS' }, { value: 'fbo', label: 'FBO' }, { value: 'fbp', label: 'FBP' }]} /></Form.Item>
|
||||||
|
<Form.Item name={['profile', 'tags']} label="标签" style={{ flex: 1 }}><Select mode="tags" tokenSeparators={[',']} placeholder="输入后回车添加" /></Form.Item>
|
||||||
|
</Space>
|
||||||
|
<Form.Item name={['profile', 'title']} label="平台标题" rules={[{ max: 1000 }]}><Input /></Form.Item>
|
||||||
|
<Form.Item name={['profile', 'description']} label="平台描述" rules={[{ max: 100000 }]}><Input.TextArea rows={4} /></Form.Item>
|
||||||
|
<Form.Item name={['profile', 'target_store_id']} hidden><Input /></Form.Item>
|
||||||
|
</Card>
|
||||||
|
<Card title="SKU 与首次上架货号" extra={<Typography.Text type="secondary">保存草稿不提交 Ozon</Typography.Text>}>
|
||||||
|
<Form.List name="variants">
|
||||||
|
{(fields, { add, remove }) => <Space direction="vertical" style={{ width: '100%' }} size={12}>
|
||||||
|
{fields.map((field, index) => <Card key={field.key} size="small" title={`SKU ${index + 1}`} extra={fields.length > 1 ? <Button type="text" danger icon={<DeleteOutlined />} onClick={() => remove(field.name)}>删除</Button> : null}>
|
||||||
|
<Form.Item name={[field.name, 'id']} hidden><Input /></Form.Item>
|
||||||
|
<Form.Item name={[field.name, 'client_key']} hidden><Input /></Form.Item>
|
||||||
|
<Form.Item name={[field.name, 'copy_from_variant_id']} hidden><Input /></Form.Item>
|
||||||
|
<Space style={{ width: '100%' }} size={12} align="start">
|
||||||
|
<Form.Item name={[field.name, 'display_name']} label="SKU 名称" style={{ flex: 2 }}><Input /></Form.Item>
|
||||||
|
<Form.Item name={[field.name, 'source_sku']} label="来源 SKU" style={{ flex: 1 }}><Input /></Form.Item>
|
||||||
|
<Form.Item name={[field.name, 'offer_id']} label="Ozon 货号" style={{ flex: 1 }}><Input /></Form.Item>
|
||||||
|
<Form.Item name={[field.name, 'purchase_cost_cny']} label="采购价(CNY)" style={{ flex: 1 }} rules={[{ pattern: /^\d+(\.\d{1,4})?$/, message: '请输入金额' }]}><Input /></Form.Item>
|
||||||
|
</Space>
|
||||||
|
</Card>)}
|
||||||
|
<Button type="dashed" icon={<PlusOutlined />} onClick={() => add({ id: null, client_key: crypto.randomUUID(), copy_from_variant_id: null, display_name: null, source_sku: null, purchase_cost_cny: null, offer_id: null } satisfies VariantValues)}>新增 SKU</Button>
|
||||||
|
</Space>}
|
||||||
|
</Form.List>
|
||||||
|
</Card>
|
||||||
|
<Divider style={{ margin: 0 }} />
|
||||||
|
<Space style={{ justifyContent: 'flex-end', width: '100%' }}><Button onClick={() => navigate('/collection')}>取消</Button><Button type="primary" htmlType="submit" loading={saving} icon={<SaveOutlined />}>保存草稿</Button></Space>
|
||||||
|
</Space>
|
||||||
|
</Form>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ProductEditPage;
|
export default ProductEditPage;
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'pricing', element: <PricingPage /> },
|
{ path: 'pricing', element: <PricingPage /> },
|
||||||
// 商品 AI 编辑页:从采集箱/在线商品进入,不在左侧菜单中出现
|
// 商品 AI 编辑页:从采集箱/在线商品进入,不在左侧菜单中出现
|
||||||
{ path: 'products/:id', element: <ProductEditPage /> },
|
{ path: 'products/:id', element: <ProductEditPage /> },
|
||||||
|
{ path: 'products/:id/editor', element: <ProductEditPage /> },
|
||||||
// 个人中心设置页:从页头用户菜单进入,不在左侧菜单中出现
|
// 个人中心设置页:从页头用户菜单进入,不在左侧菜单中出现
|
||||||
{ path: 'settings/profile', element: <ProfileSettingsPage /> },
|
{ path: 'settings/profile', element: <ProfileSettingsPage /> },
|
||||||
{ path: 'settings/password', element: <PasswordSettingsPage /> },
|
{ path: 'settings/password', element: <PasswordSettingsPage /> },
|
||||||
|
|||||||
@@ -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<ProductEditorContext>(`/collection-items/${collectionItemId}/editor-contexts`, undefined, {
|
||||||
|
headers: { 'X-Atlas-Platform-Id': platformId, 'Idempotency-Key': crypto.randomUUID() },
|
||||||
|
}),
|
||||||
|
read: (productId: string, platformId: string) =>
|
||||||
|
api.get<ProductEditorSnapshot>(`/products/${productId}/editor`, {
|
||||||
|
headers: { 'X-Atlas-Platform-Id': platformId },
|
||||||
|
}),
|
||||||
|
save: (
|
||||||
|
productId: string,
|
||||||
|
platformId: string,
|
||||||
|
revisions: { product: number; profile: number },
|
||||||
|
payload: SaveProductEditorDraftRequest,
|
||||||
|
) => api.put<SaveProductEditorDraftResponse>(`/products/${productId}/editor-draft`, payload, {
|
||||||
|
headers: {
|
||||||
|
'X-Atlas-Platform-Id': platformId,
|
||||||
|
'Idempotency-Key': crypto.randomUUID(),
|
||||||
|
'If-Match': `"${revisions.product}:${revisions.profile}"`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
+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) | 已设计 | 商品编辑、SKU、类目、属性、AI、媒体选择、定价确认与部分 SKU 首次上架。 |
|
| [`product-editor-and-publishing.md`](./product-editor-and-publishing.md) | M3.1 部分已实现 | 编辑上下文、只读快照和产品/平台文案/SKU 的原子草稿保存;类目/属性、媒体、定价、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 契约。后续商品模块会迁入本目录并替换其中的路径和细节。
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# 商品编辑与首次上架 API
|
# 商品编辑与首次上架 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 首次导入状态和在线商品不能跨平台/门店复用。
|
> 此处的商品始终是采集箱内的 Atlas 草稿。所有平台范围接口要求 `X-Atlas-Platform-Id`。商品通用资料可以跨平台复用;`product_platform_profile`、SKU 首次导入状态和在线商品不能跨平台/门店复用。
|
||||||
|
|
||||||
## 1. 进入编辑与读取编辑器
|
## 1. 进入编辑与读取编辑器
|
||||||
|
|||||||
@@ -161,6 +161,21 @@
|
|||||||
- 1688、淘宝、天猫已加入受限 MAIN-world 商品状态桥:1688 仅投影公开商品的画廊、SKU、价格、参数等固定字段;淘宝/天猫仅投影 `__ICE_APP_CONTEXT__` 中的公开商品结果。桥不提供任意 window 读取、Cookie、请求头或通用 fetch。新采集器将这些结构化数据、JSON-LD、受限详情 DOM 与既有图片 profile 映射为 Atlas 新 `CollectedProductPayload`。
|
- 1688、淘宝、天猫已加入受限 MAIN-world 商品状态桥:1688 仅投影公开商品的画廊、SKU、价格、参数等固定字段;淘宝/天猫仅投影 `__ICE_APP_CONTEXT__` 中的公开商品结果。桥不提供任意 window 读取、Cookie、请求头或通用 fetch。新采集器将这些结构化数据、JSON-LD、受限详情 DOM 与既有图片 profile 映射为 Atlas 新 `CollectedProductPayload`。
|
||||||
- `/collection-test/{collectionItemId}` 现单独展示商品描述、类目路径、全部特征表、规格轴、SKU 草稿、素材候选,并保留规范化载荷和原始证据,供解析核对。扩展解析器测试覆盖 JSON-LD 描述兜底、完整特征合并和图片白名单。
|
- `/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. 已确认产品决策
|
## 2. 已确认产品决策
|
||||||
|
|
||||||
| 主题 | 决策 |
|
| 主题 | 决策 |
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
|
|
||||||
### 1. SKU 与平台发布配置的同商品归属
|
### 1. SKU 与平台发布配置的同商品归属
|
||||||
|
|
||||||
**状态:已决策,待数据库约束设计。**
|
**状态:M3.0 已落地数据库约束(2026-09-09);发布任务、在线镜像等后续表必须复用该归属链。**
|
||||||
|
|
||||||
`product_variant_platform_listings` 同时引用平台发布配置(profile)和通用 SKU(variant),但必须强制:`variant.product_id = profile.product_id`。同一条归属链也适用于属性、报价、发布任务项和在线镜像映射。
|
`product_variant_platform_listings` 同时引用平台发布配置(profile)和通用 SKU(variant),但必须强制:`variant.product_id = profile.product_id`。同一条归属链也适用于属性、报价、发布任务项和在线镜像映射。
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
|
|
||||||
### 2. 类目切换后未映射属性的持久化
|
### 2. 类目切换后未映射属性的持久化
|
||||||
|
|
||||||
**状态:已决策,待数据模型补齐。**
|
**状态:M3.0 已落地持久化表(2026-09-09);类目切换写入与恢复操作随编辑草稿保存实现。**
|
||||||
|
|
||||||
切换目标平台类目后,旧类目中无法映射的属性值必须作为 Atlas 草稿保留,供用户查看、复用或手动处理,不能只出现在一次 API response 的 warning 中。
|
切换目标平台类目后,旧类目中无法映射的属性值必须作为 Atlas 草稿保留,供用户查看、复用或手动处理,不能只出现在一次 API response 的 warning 中。
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
|
|
||||||
### 3. 乐观锁的统一实施
|
### 3. 乐观锁的统一实施
|
||||||
|
|
||||||
**状态:已决策,待 Repository 规则和测试落地。**
|
**状态:M3.1 已使用产品/profile 双 `revision` 条件更新、事务回滚和集成测试落地;后续编辑资源不得绕过该规则。**
|
||||||
|
|
||||||
`revision` 不依赖数据库触发器自动递增。所有可编辑资源的更新必须由统一 Repository/数据访问层执行:
|
`revision` 不依赖数据库触发器自动递增。所有可编辑资源的更新必须由统一 Repository/数据访问层执行:
|
||||||
|
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ export * from './auth';
|
|||||||
export * from './stores';
|
export * from './stores';
|
||||||
export * from './watermarks';
|
export * from './watermarks';
|
||||||
export * from './media';
|
export * from './media';
|
||||||
|
export * from './product-editor';
|
||||||
|
|||||||
@@ -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<typeof saveProductEditorDraftRequestSchema>;
|
||||||
|
export type SaveProductEditorDraftResponse = {
|
||||||
|
product_revision: number;
|
||||||
|
profile_revision: number;
|
||||||
|
created_variants: Array<{ client_key: string; id: string }>;
|
||||||
|
warnings: Array<{ code: string; message: string }>;
|
||||||
|
};
|
||||||
@@ -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 $$;
|
||||||
Reference in New Issue
Block a user