feat(edit): 商品编辑页开发
This commit is contained in:
@@ -98,6 +98,7 @@ export class ExtensionService {
|
||||
product.rows[0].id,
|
||||
1,
|
||||
payload,
|
||||
false,
|
||||
);
|
||||
return {
|
||||
status: 201,
|
||||
@@ -191,6 +192,19 @@ export class ExtensionService {
|
||||
payload: CollectedProductPayload,
|
||||
) {
|
||||
const { status, sourcePrice } = sourceState(payload);
|
||||
// Once a platform profile exists, product_variants are no longer a
|
||||
// disposable collection projection: they may carry user edits and are
|
||||
// referenced by platform listings. A re-collection refreshes the
|
||||
// source evidence/candidates, but must not overwrite that draft or
|
||||
// sever a SKU's publishing history.
|
||||
const profileCount = await tx.query<{ count: string }>(
|
||||
`SELECT count(*)::text AS count
|
||||
FROM atlas.product_platform_profiles
|
||||
WHERE product_id = $1`,
|
||||
[productId],
|
||||
);
|
||||
const preserveEditorDraft =
|
||||
Number(profileCount.rows[0]?.count ?? 0) > 0;
|
||||
// 不删除旧候选/任务记录:运行中的 Worker 可能已拿到它。取消并保留审计记录,
|
||||
// 新快照在查询和 Worker 中均按 source_snapshot_revision 隔离。
|
||||
await tx.query(
|
||||
@@ -222,25 +236,14 @@ export class ExtensionService {
|
||||
const snapshotRevision = Number(item.rows[0]?.source_snapshot_revision);
|
||||
if (!Number.isSafeInteger(snapshotRevision) || snapshotRevision < 1)
|
||||
throw new Error('采集来源快照版本更新失败');
|
||||
await tx.query(
|
||||
`UPDATE atlas.products SET source_title = $1, updated_at = now(), revision = revision + 1 WHERE id = $2`,
|
||||
[payload.common.title?.value ?? null, productId],
|
||||
);
|
||||
// The historical task rows must remain auditable after replacing the
|
||||
// source snapshot. Clear only their optional variant pointer before
|
||||
// deleting old variants. The original composite FK used ON DELETE SET
|
||||
// NULL and PostgreSQL tried to null both key columns, including the
|
||||
// required workspace_id.
|
||||
await tx.query(
|
||||
`UPDATE atlas.collection_asset_transfers
|
||||
SET source_variant_id = NULL, updated_at = now()
|
||||
WHERE product_id = $1 AND source_variant_id IS NOT NULL`,
|
||||
[productId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.product_variants WHERE product_id = $1',
|
||||
[productId],
|
||||
);
|
||||
if (!preserveEditorDraft) {
|
||||
await tx.query(
|
||||
`UPDATE atlas.products
|
||||
SET source_title = $1, updated_at = now(), revision = revision + 1
|
||||
WHERE id = $2`,
|
||||
[payload.common.title?.value ?? null, productId],
|
||||
);
|
||||
}
|
||||
await this.writeSourceSnapshotRows(
|
||||
tx,
|
||||
workspaceId,
|
||||
@@ -248,6 +251,7 @@ export class ExtensionService {
|
||||
productId,
|
||||
snapshotRevision,
|
||||
payload,
|
||||
preserveEditorDraft,
|
||||
);
|
||||
return snapshotRevision;
|
||||
}
|
||||
@@ -259,6 +263,7 @@ export class ExtensionService {
|
||||
productId: string,
|
||||
snapshotRevision: number,
|
||||
payload: CollectedProductPayload,
|
||||
preserveEditorDraft: boolean,
|
||||
) {
|
||||
const verifiedVariants = payload.variants.filter(
|
||||
(variant) => variant.sourceVariantKey.trim().length > 0,
|
||||
@@ -272,23 +277,99 @@ export class ExtensionService {
|
||||
displayName: '默认 SKU',
|
||||
},
|
||||
];
|
||||
const sourceKeys = variantRows
|
||||
.map((variant) => variant.sourceVariantKey)
|
||||
.filter((key): key is string => !!key);
|
||||
const existing = await tx.query<{
|
||||
id: string;
|
||||
source_variant_key: string | null;
|
||||
deleted_at: Date | string | null;
|
||||
}>(
|
||||
`SELECT id, source_variant_key, deleted_at
|
||||
FROM atlas.product_variants
|
||||
WHERE product_id = $1
|
||||
AND (source_variant_key = ANY($2::text[]) OR source_variant_key IS NULL)
|
||||
ORDER BY sort_order, id`,
|
||||
[productId, sourceKeys],
|
||||
);
|
||||
const existingBySourceKey = new Map(
|
||||
existing.rows
|
||||
.filter((variant) => variant.source_variant_key)
|
||||
.map((variant) => [variant.source_variant_key!, variant]),
|
||||
);
|
||||
// A capture without a stable source key has exactly one synthetic
|
||||
// default SKU. Reuse an existing unkeyed row when possible; this keeps
|
||||
// its platform mapping rather than attempting a duplicate insert.
|
||||
const existingDefault = existing.rows.find(
|
||||
(variant) => variant.source_variant_key === null,
|
||||
);
|
||||
const variants = new Map<string, string>();
|
||||
const retainedVariantIds: string[] = [];
|
||||
for (let index = 0; index < variantRows.length; index += 1) {
|
||||
const variant = variantRows[index];
|
||||
const inserted = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.product_variants (workspace_id, product_id, source_variant_key, source_sku, display_name, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||
[
|
||||
workspaceId,
|
||||
const matched = variant.sourceVariantKey
|
||||
? existingBySourceKey.get(variant.sourceVariantKey)
|
||||
: existingDefault;
|
||||
let variantId: string;
|
||||
if (matched) {
|
||||
variantId = matched.id;
|
||||
// A user may have deliberately removed an Atlas SKU after
|
||||
// editing began. Keep that decision; its source evidence is
|
||||
// still present in normalized_payload, but it is not silently
|
||||
// revived into the editable/publishable SKU set.
|
||||
if (!preserveEditorDraft) {
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_variants
|
||||
SET source_sku = $1, display_name = $2, sort_order = $3,
|
||||
deleted_at = NULL, updated_at = now()
|
||||
WHERE id = $4`,
|
||||
[
|
||||
variant.sourceSku ?? null,
|
||||
variant.displayName,
|
||||
index,
|
||||
variantId,
|
||||
],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const inserted = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.product_variants
|
||||
(workspace_id, product_id, source_variant_key, source_sku, display_name, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||
[
|
||||
workspaceId,
|
||||
productId,
|
||||
variant.sourceVariantKey,
|
||||
variant.sourceSku ?? null,
|
||||
variant.displayName,
|
||||
index,
|
||||
],
|
||||
);
|
||||
variantId = inserted.rows[0].id;
|
||||
await this.ensureProfileListingsForVariant(
|
||||
tx,
|
||||
productId,
|
||||
variant.sourceVariantKey,
|
||||
variant.sourceSku ?? null,
|
||||
variant.displayName,
|
||||
index,
|
||||
],
|
||||
);
|
||||
variantId,
|
||||
);
|
||||
}
|
||||
retainedVariantIds.push(variantId);
|
||||
if (variant.sourceVariantKey)
|
||||
variants.set(variant.sourceVariantKey, inserted.rows[0].id);
|
||||
variants.set(variant.sourceVariantKey, variantId);
|
||||
}
|
||||
// Before an editor profile exists, variants are still just the latest
|
||||
// source projection, so a missing source SKU can be safely hidden. Do
|
||||
// not physically delete it: the partial source-key unique index does
|
||||
// not exclude soft-deleted rows, and a later refresh may legitimately
|
||||
// rediscover the same stable key. After editing begins, omission from a
|
||||
// source refresh must never act as a user-requested SKU deletion.
|
||||
if (!preserveEditorDraft) {
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_variants
|
||||
SET deleted_at = now(), updated_at = now()
|
||||
WHERE product_id = $1 AND deleted_at IS NULL
|
||||
AND NOT (id = ANY($2::uuid[]))`,
|
||||
[productId, retainedVariantIds],
|
||||
);
|
||||
}
|
||||
for (const asset of payload.assets) {
|
||||
await tx.query(
|
||||
@@ -323,6 +404,28 @@ export class ExtensionService {
|
||||
);
|
||||
}
|
||||
|
||||
private async ensureProfileListingsForVariant(
|
||||
tx: { query: Database['query'] },
|
||||
productId: string,
|
||||
variantId: string,
|
||||
) {
|
||||
// A source refresh may discover a new real SKU after a user has
|
||||
// already entered one or more platform editors. Every existing profile
|
||||
// needs a fresh, explicitly not-listed mapping for that SKU; imported
|
||||
// mappings for other SKU rows are untouched.
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.product_variant_platform_listings
|
||||
(workspace_id, product_id, profile_id, platform_id, product_variant_id, store_id, currency_code)
|
||||
SELECT current_setting('app.workspace_id')::uuid, $1, p.id, p.platform_id,
|
||||
$2, p.target_store_id, s.currency_code
|
||||
FROM atlas.product_platform_profiles p
|
||||
JOIN atlas.stores s ON s.id = p.target_store_id
|
||||
WHERE p.product_id = $1
|
||||
ON CONFLICT (workspace_id, profile_id, product_variant_id) DO NOTHING`,
|
||||
[productId, variantId],
|
||||
);
|
||||
}
|
||||
|
||||
private async materialResponse(
|
||||
tx: { query: Database['query'] },
|
||||
collectionItemId: string,
|
||||
@@ -360,7 +463,9 @@ export class ExtensionService {
|
||||
editor_entry_path: `/collection-test/${collectionItemId}`,
|
||||
materialization: {
|
||||
total: Number(count.rows[0]?.total ?? 0),
|
||||
pending_selection: Number(count.rows[0]?.pending_selection ?? 0),
|
||||
pending_selection: Number(
|
||||
count.rows[0]?.pending_selection ?? 0,
|
||||
),
|
||||
queued: Number(count.rows[0]?.queued ?? 0),
|
||||
succeeded: Number(count.rows[0]?.succeeded ?? 0),
|
||||
failed: Number(count.rows[0]?.failed ?? 0),
|
||||
|
||||
Reference in New Issue
Block a user