feat(edit|extension): 编辑页类目采集开发

This commit is contained in:
R524809
2026-09-22 13:47:24 +08:00
parent 2b06ef0692
commit 95a2a37f1b
12 changed files with 30302 additions and 51 deletions
@@ -19,8 +19,8 @@ import { bindEditorFields } from './product-editor.attribute-bindings.js';
import { ProductEditorValidationOperations } from './product-editor.validation.js'; import { ProductEditorValidationOperations } from './product-editor.validation.js';
import { ProductEditorPublishPreflightOperations } from './product-editor.publish-preflight.js'; import { ProductEditorPublishPreflightOperations } from './product-editor.publish-preflight.js';
import { import {
capturedOzonCategorySignals,
latestProductAiSourceMaterial, latestProductAiSourceMaterial,
normalizedCategoryPath as normalizeCapturedCategoryPath,
productAiSourceMaterial as resolveSourceMaterial, productAiSourceMaterial as resolveSourceMaterial,
productAiSourceOverrides as selectSourceMaterialOverrides, productAiSourceOverrides as selectSourceMaterialOverrides,
} from './product-editor.source-material.js'; } from './product-editor.source-material.js';
@@ -200,6 +200,11 @@ export class ProductEditorService {
product, product,
initialProfile, initialProfile,
); );
const capturedCategoryMatch = await this.capturedCategoryMatch(
transaction,
product,
profile.platform_id,
);
const variants = await transaction.query<{ const variants = await transaction.query<{
id: string; id: string;
source_variant_key: string | null; source_variant_key: string | null;
@@ -460,6 +465,7 @@ export class ProductEditorService {
})), })),
source_material: sourceMaterial, source_material: sourceMaterial,
source_material_state: sourceMaterialResult.state, source_material_state: sourceMaterialResult.state,
captured_category_match: capturedCategoryMatch,
ai_input: { ai_input: {
source_material: sourceMaterial, source_material: sourceMaterial,
default_physical_spec: defaultPhysicalSpec, default_physical_spec: defaultPhysicalSpec,
@@ -1740,8 +1746,8 @@ export class ProductEditorService {
} }
/** /**
* Ozon 采集只保存类目面包屑而非缓存 UUID。仅当发布配置尚未保存类目时, * Ozon 公开页仅验证首级面包屑和 Type_0 类型值稳定;二、三级导航不可
* 才能用精确路径匹配回填;之后的复采绝不能覆盖用户或既有持久化选择。 * 用于发布类目判定。只有唯一活动叶子候选才可自动回填,已有选择绝不覆盖。
*/ */
private async applyCapturedCategory( private async applyCapturedCategory(
transaction: DatabaseTransaction, transaction: DatabaseTransaction,
@@ -1749,34 +1755,13 @@ export class ProductEditorService {
profile: ProfileRow, profile: ProfileRow,
): Promise<ProfileRow> { ): Promise<ProfileRow> {
if (profile.platform_product_category_id) return profile; if (profile.platform_product_category_id) return profile;
const categories = latestProductAiSourceMaterial(product).categories; const match = await this.capturedCategoryMatch(
const sourcePath = normalizeCapturedCategoryPath(categories); transaction,
if (!sourcePath) return profile; product,
profile.platform_id,
const sourcePlatform = await transaction.query<{ code: string }>(
`SELECT platform.code
FROM atlas.collection_items item
JOIN atlas.platforms platform ON platform.id = item.source_platform_id
WHERE item.id = $1 AND item.deleted_at IS NULL`,
[product.collection_item_id],
); );
if (sourcePlatform.rows[0]?.code.toUpperCase() !== 'OZON') if (match.status !== 'matched') return profile;
return profile; const matched = match.candidates[0];
const category = await transaction.query<{
id: string;
external_type_id: string | null;
}>(
`SELECT id, external_type_id
FROM atlas.platform_product_categories
WHERE platform_id = $1 AND is_active
AND lower(regexp_replace(path, '\\s+', ' ', 'g')) = $2
ORDER BY id
LIMIT 1`,
[profile.platform_id, sourcePath],
);
const matched = category.rows[0];
if (!matched) return profile;
const updated = await transaction.query<{ id: string }>( const updated = await transaction.query<{ id: string }>(
`UPDATE atlas.product_platform_profiles `UPDATE atlas.product_platform_profiles
@@ -1792,6 +1777,93 @@ export class ProductEditorService {
: profile; : profile;
} }
/** 返回 Ozon 来源信号对应的可发布叶子候选,供自动回填或用户确认。 */
private async capturedCategoryMatch(
transaction: DatabaseTransaction,
product: ProductRow,
platformId: string,
): Promise<ProductEditorSnapshot['captured_category_match']> {
const unavailable = {
status: 'unavailable' as const,
root_category_name: null,
type_name: null,
candidates: [],
};
const sourcePlatform = await transaction.query<{ code: string }>(
`SELECT platform.code
FROM atlas.collection_items item
JOIN atlas.platforms platform ON platform.id = item.source_platform_id
WHERE item.id = $1 AND item.deleted_at IS NULL`,
[product.collection_item_id],
);
if (sourcePlatform.rows[0]?.code.toUpperCase() !== 'OZON') return unavailable;
const signals = capturedOzonCategorySignals(
latestProductAiSourceMaterial(product),
);
if (!signals.rootCategoryName || !signals.typeName) {
return {
...unavailable,
root_category_name: signals.rootCategoryName,
type_name: signals.typeName,
};
}
const candidates = await transaction.query<{
id: string;
name: string;
path: string;
external_category_id: string;
external_type_id: string;
}>(
`WITH RECURSIVE ancestors AS (
SELECT category.id AS candidate_id, category.parent_id AS ancestor_parent_id,
category.name AS ancestor_name
FROM atlas.platform_product_categories category
WHERE category.platform_id = $1
AND category.is_active
AND category.external_type_id IS NOT NULL
AND category.external_type_id <> '0'
AND NOT EXISTS (
SELECT 1 FROM atlas.platform_product_categories child
WHERE child.platform_id = category.platform_id
AND child.parent_id = category.id
)
AND lower(regexp_replace(
coalesce(category.source_payload->>'type_name', category.name),
'\\s+', ' ', 'g'
)) = lower(regexp_replace($2, '\\s+', ' ', 'g'))
UNION ALL
SELECT ancestors.candidate_id, parent.parent_id, parent.name
FROM ancestors
JOIN atlas.platform_product_categories parent
ON parent.id = ancestors.ancestor_parent_id
AND parent.platform_id = $1
)
SELECT category.id, category.name, category.path,
category.external_category_id, category.external_type_id
FROM ancestors
JOIN atlas.platform_product_categories category
ON category.id = ancestors.candidate_id
WHERE ancestors.ancestor_parent_id IS NULL
AND lower(regexp_replace(ancestors.ancestor_name, '\\s+', ' ', 'g'))
= lower(regexp_replace($3, '\\s+', ' ', 'g'))
ORDER BY category.path, category.id
LIMIT 20`,
[platformId, signals.typeName, signals.rootCategoryName],
);
const status = candidates.rows.length === 1
? 'matched'
: candidates.rows.length
? 'ambiguous'
: 'unmatched';
return {
status,
root_category_name: signals.rootCategoryName,
type_name: signals.typeName,
candidates: candidates.rows,
};
}
/** 兼容旧客户端的门店类目 UUID,并统一解析成平台类目 UUID。 */ /** 兼容旧客户端的门店类目 UUID,并统一解析成平台类目 UUID。 */
private async resolveSelectedPlatformCategoryId( private async resolveSelectedPlatformCategoryId(
transaction: DatabaseTransaction, transaction: DatabaseTransaction,
@@ -134,14 +134,16 @@ function objectValue(value: unknown): Record<string, unknown> | undefined {
: undefined; : undefined;
} }
/** 规范化来源类目面包屑,以便和缓存路径做精确匹配。 */ /**
export function normalizedCategoryPath( * Ozon 公开详情页的后二、三级导航并不稳定;自动类目匹配只使用首级
categories: Array<{ name: string }>, * 面包屑和页面结构化特征中的 Type_0 值。该信号缺失时必须交给人工选择。
): string | null { */
const names = categories export function capturedOzonCategorySignals(
.map((category) => source: ProductAiSourceMaterial,
category.name.normalize('NFKC').trim().replace(/\s+/g, ' ').toLocaleLowerCase(), ): { rootCategoryName: string | null; typeName: string | null } {
) const rootCategoryName = source.categories[0]?.name?.trim() || null;
.filter(Boolean); const typeName = source.attributes.find((attribute) =>
return names.length ? names.join(' › ') : null; /(?:^|:)type_0$/i.test(attribute.key.trim()),
)?.value.trim() || null;
return { rootCategoryName, typeName };
} }
+50 -5
View File
@@ -1355,13 +1355,19 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
); );
}); });
it('M3.5 按 Ozon 采集层级回填空类目,且绝不覆盖已保存选择', async () => { it('M3.5 按 Ozon 一级类目和类型回填唯一候选,并返回多候选供确认', async () => {
const suffix = randomUUID(); const suffix = randomUUID();
const root = await seedStoreCategory(
`m35-root-${suffix}`,
'0',
'M3.5 来源',
'M3.5 来源',
);
const captured = await seedStoreCategory( const captured = await seedStoreCategory(
`m35-source-${suffix}`, `m35-source-${suffix}`,
'source-type', 'source-type',
'M3.5 采集叶', 'M3.5 类型',
'M3.5 来源 › M3.5 采集叶', 'M3.5 来源 › M3.5 类型',
); );
const manual = await seedStoreCategory( const manual = await seedStoreCategory(
`m35-manual-${suffix}`, `m35-manual-${suffix}`,
@@ -1369,8 +1375,14 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
'M3.5 手动叶', 'M3.5 手动叶',
'M3.5 手动 › M3.5 手动叶', 'M3.5 手动 › M3.5 手动叶',
); );
seededCategoryIds.push(captured.id, manual.id); seededCategoryIds.push(root.id, captured.id, manual.id);
await database.workspaceTransaction(workspaceId, async (tx) => { await database.workspaceTransaction(workspaceId, async (tx) => {
await tx.query(
`UPDATE atlas.platform_product_categories
SET parent_id = $1, source_payload = '{"type_name":"M3.5 类型"}'::jsonb
WHERE id = $2`,
[root.id, captured.id],
);
await tx.query( await tx.query(
`UPDATE atlas.collection_items `UPDATE atlas.collection_items
SET source_kind = 'platform', source_platform_id = $1, SET source_kind = 'platform', source_platform_id = $1,
@@ -1382,7 +1394,9 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
common: { common: {
categories: [ categories: [
{ key: 'source-root', name: ' M3.5 来源 ' }, { key: 'source-root', name: ' M3.5 来源 ' },
{ key: 'source-leaf', name: 'M3.5 采集叶' }, ],
attributes: [
{ key: 'ozon:Type_0', name: '类型', value: 'M3.5 类型' },
], ],
}, },
}), }),
@@ -1411,6 +1425,37 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
'source_capture', 'source_capture',
); );
const duplicate = await seedStoreCategory(
`m35-duplicate-${suffix}`,
'duplicate-type',
'M3.5 类型',
'M3.5 来源 › 另一分支 › M3.5 类型',
);
seededCategoryIds.push(duplicate.id);
await database.workspaceTransaction(workspaceId, async (tx) => {
await tx.query(
`UPDATE atlas.platform_product_categories
SET parent_id = $1, source_payload = '{"type_name":"M3.5 类型"}'::jsonb
WHERE id = $2`,
[root.id, duplicate.id],
);
await tx.query(
`UPDATE atlas.product_platform_profiles
SET platform_product_category_id = NULL, platform_type_id = NULL,
category_source = NULL
WHERE id = $1`,
[first.profileId],
);
});
const ambiguous = await request(
`/api/products/${first.productId}/editor`,
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
);
assert.equal(ambiguous.response.status, 200);
assert.equal(ambiguous.body.data.profile.category.id, null);
assert.equal(ambiguous.body.data.captured_category_match.status, 'ambiguous');
assert.equal(ambiguous.body.data.captured_category_match.candidates.length, 2);
await database.workspaceTransaction(workspaceId, (tx) => await database.workspaceTransaction(workspaceId, (tx) =>
tx.query( tx.query(
`UPDATE atlas.product_platform_profiles `UPDATE atlas.product_platform_profiles
@@ -1127,6 +1127,16 @@ const ProductEditPage = () => {
categoryPaths={categories.paths} categoryPaths={categories.paths}
categoryNotFoundContent={categoryNotFoundContent} categoryNotFoundContent={categoryNotFoundContent}
onCategorySearch={setCategorySearch} onCategorySearch={setCategorySearch}
onSelectCategoryCandidate={(categoryId) => {
form.setFieldsValue({
profile: {
platform_product_category_id: categoryId,
category_source: 'manual',
},
attributes: [],
});
setDirty(true);
}}
usesVariantPackageWeight={usesVariantPackageWeight} usesVariantPackageWeight={usesVariantPackageWeight}
usesVariantPackageDimensions={usesVariantPackageDimensions} usesVariantPackageDimensions={usesVariantPackageDimensions}
onUseVariantPackageWeight={useVariantPackageWeight} onUseVariantPackageWeight={useVariantPackageWeight}
@@ -115,6 +115,29 @@
white-space: nowrap; white-space: nowrap;
} }
/* 来源页层级仅用于展示和诊断,不表示已选中的可发布类目。 */
.collected-category-breadcrumb {
display: flex;
flex-wrap: wrap;
gap: 2px 0;
padding: 9px 10px;
border: 1px solid #ece9f1;
border-radius: 8px;
background: #fbfaff;
color: #352f43;
font-size: 12px;
line-height: 1.55;
}
.collected-category-breadcrumb .ant-breadcrumb-link {
overflow-wrap: anywhere;
}
.collected-category-hint.ant-typography {
display: block;
margin-top: 6px;
font-size: 12px;
line-height: 1.5;
}
/* 规格 / 参数列表 */ /* 规格 / 参数列表 */
.collected-attributes { .collected-attributes {
border: 1px solid #ece9f1; border: 1px solid #ece9f1;
@@ -1,5 +1,16 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Alert, Button, Card, Collapse, Image, Input, Space, Tag, Typography } from 'antd'; import {
Alert,
Breadcrumb,
Button,
Card,
Collapse,
Image,
Input,
Space,
Tag,
Typography,
} from 'antd';
import type { import type {
CollectionItemInspection, CollectionItemInspection,
ProductAiSourceField, ProductAiSourceField,
@@ -56,6 +67,8 @@ export function CollectedProductInfoPanel({
onAcceptLatest, onAcceptLatest,
}: Props) { }: Props) {
const sourceDescription = collected?.description ?? ''; const sourceDescription = collected?.description ?? '';
// 类目自动回填始终基于最新采集快照,不能被 AI 输入素材的用户覆盖影响。
const capturedCategories = snapshot.source_material_state.latest.categories;
const [descriptionDraft, setDescriptionDraft] = useState(sourceDescription); const [descriptionDraft, setDescriptionDraft] = useState(sourceDescription);
useEffect(() => setDescriptionDraft(sourceDescription), [sourceDescription]); useEffect(() => setDescriptionDraft(sourceDescription), [sourceDescription]);
// 采集图片按角色分三组展示;同组内按 URL 去重(同一张图可能同时出现在 // 采集图片按角色分三组展示;同组内按 URL 去重(同一张图可能同时出现在
@@ -185,6 +198,28 @@ export function CollectedProductInfoPanel({
</div> </div>
</div> </div>
</div> </div>
<div className="collected-field">
<span>来源类目面包屑</span>
{capturedCategories.length ? (
<>
<Breadcrumb
className="collected-category-breadcrumb"
separator="›"
items={capturedCategories.map((category, index) => ({
key: `${category.key}:${index}`,
title: category.name,
}))}
/>
<Typography.Text type="secondary" className="collected-category-hint">
自动匹配仅使用一级类目与商品类型;后续层级仅作为来源参考。
</Typography.Text>
</>
) : (
<Typography.Text type="secondary">
本次采集未取得来源类目。
</Typography.Text>
)}
</div>
<div className="collected-field"> <div className="collected-field">
<span>规格 / 参数</span> <span>规格 / 参数</span>
{collected?.attributes.length ? ( {collected?.attributes.length ? (
@@ -108,7 +108,7 @@ export function ProductAttributeSection({
</Form.Item> </Form.Item>
<Button <Button
type="text" type="text"
icon={<PlusCircleOutlined />} icon={<PlusCircleOutlined style={{ fontSize: 20 }} />}
aria-label="按 SKU 分别填写主题标签" aria-label="按 SKU 分别填写主题标签"
onClick={onUseVariantTags} onClick={onUseVariantTags}
/> />
@@ -123,7 +123,7 @@ export function ProductAttributeSection({
</Form.Item> </Form.Item>
<Button <Button
type="text" type="text"
icon={<PlusCircleOutlined />} icon={<PlusCircleOutlined style={{ fontSize: 20 }} />}
aria-label="按 SKU 分别填写产品描述" aria-label="按 SKU 分别填写产品描述"
onClick={onUseVariantDescription} onClick={onUseVariantDescription}
/> />
@@ -50,6 +50,40 @@
display: flex; display: flex;
align-items: center; align-items: center;
} }
/* 多候选只提供确认入口;实际发布类目仍由用户保存后的 UUID 决定。 */
.captured-category-candidates {
margin: -10px 0 14px 100px;
padding: 10px 12px;
border: 1px solid #d8d0ff;
border-radius: 8px;
background: #faf9ff;
}
.captured-category-candidates-title {
color: #5e5273;
font-size: 12px;
line-height: 1.55;
}
.captured-category-candidate-list {
display: grid;
gap: 8px;
margin-top: 9px;
}
.captured-category-candidate-list .ant-radio-wrapper {
align-items: flex-start;
margin-inline-end: 0;
color: #352f43;
line-height: 1.45;
}
.captured-category-candidate-path,
.captured-category-candidate-id {
display: block;
}
.captured-category-candidate-id {
margin-top: 2px;
color: #77718a;
font-size: 12px;
}
/* 包装尺寸跟随主要信息卡片的实际宽度,和表单 Label 的换行阈值保持一致。 */ /* 包装尺寸跟随主要信息卡片的实际宽度,和表单 Label 的换行阈值保持一致。 */
@container (max-width: 479px) { @container (max-width: 479px) {
.package-dimensions-controls { .package-dimensions-controls {
@@ -59,4 +93,7 @@
.package-dimension-separator { .package-dimension-separator {
display: none; display: none;
} }
.captured-category-candidates {
margin-left: 0;
}
} }
@@ -1,4 +1,4 @@
import { Button, Card, Cascader, Form, Input, InputNumber, Select, Tooltip } from 'antd'; import { Button, Card, Cascader, Form, Input, InputNumber, Radio, Select, Tooltip } from 'antd';
import type { FormInstance } from 'antd'; import type { FormInstance } from 'antd';
import { PlusCircleOutlined } from '@ant-design/icons'; import { PlusCircleOutlined } from '@ant-design/icons';
import type { ProductEditorSnapshot } from '@atlas/contracts'; import type { ProductEditorSnapshot } from '@atlas/contracts';
@@ -42,7 +42,7 @@ function PackageFields({
<Tooltip title="按 SKU 分别填写包装重量"> <Tooltip title="按 SKU 分别填写包装重量">
<Button <Button
type="text" type="text"
icon={<PlusCircleOutlined />} icon={<PlusCircleOutlined style={{ fontSize: 20 }} />}
aria-label="按 SKU 分别填写包装重量" aria-label="按 SKU 分别填写包装重量"
onClick={onUseVariantPackageWeight} onClick={onUseVariantPackageWeight}
/> />
@@ -68,7 +68,7 @@ function PackageFields({
<Tooltip title="按 SKU 分别填写包装尺寸"> <Tooltip title="按 SKU 分别填写包装尺寸">
<Button <Button
type="text" type="text"
icon={<PlusCircleOutlined />} icon={<PlusCircleOutlined style={{ fontSize: 20 }} />}
aria-label="按 SKU 分别填写包装尺寸" aria-label="按 SKU 分别填写包装尺寸"
onClick={onUseVariantPackageDimensions} onClick={onUseVariantPackageDimensions}
/> />
@@ -88,6 +88,7 @@ type Props = {
categoryPaths: Map<string, string[]>; categoryPaths: Map<string, string[]>;
categoryNotFoundContent: string; categoryNotFoundContent: string;
onCategorySearch: (query: string) => void; onCategorySearch: (query: string) => void;
onSelectCategoryCandidate: (categoryId: string) => void;
usesVariantPackageWeight: boolean; usesVariantPackageWeight: boolean;
usesVariantPackageDimensions: boolean; usesVariantPackageDimensions: boolean;
onUseVariantPackageWeight: () => void; onUseVariantPackageWeight: () => void;
@@ -108,6 +109,7 @@ export function ProductMainInfoSection({
categoryPaths, categoryPaths,
categoryNotFoundContent, categoryNotFoundContent,
onCategorySearch, onCategorySearch,
onSelectCategoryCandidate,
usesVariantPackageWeight, usesVariantPackageWeight,
usesVariantPackageDimensions, usesVariantPackageDimensions,
onUseVariantPackageWeight, onUseVariantPackageWeight,
@@ -135,6 +137,31 @@ export function ProductMainInfoSection({
}))} }))}
/> />
</Form.Item> </Form.Item>
{snapshot.captured_category_match.status === 'ambiguous' &&
!selectedCategoryId && (
<div className="captured-category-candidates span-full">
<div className="captured-category-candidates-title">
根据来源一级类目“{snapshot.captured_category_match.root_category_name}”
与商品类型“{snapshot.captured_category_match.type_name}”找到多个候选,请确认。
</div>
<Radio.Group
className="captured-category-candidate-list"
onChange={(event) => onSelectCategoryCandidate(event.target.value)}
>
{snapshot.captured_category_match.candidates.map((candidate) => (
<Radio key={candidate.id} value={candidate.id}>
<span className="captured-category-candidate-path">
{candidate.path}
</span>
<span className="captured-category-candidate-id">
类目 ID:{candidate.external_category_id} · 类型 ID:
{candidate.external_type_id}
</span>
</Radio>
))}
</Radio.Group>
</div>
)}
<Form.Item <Form.Item
name={['profile', 'platform_product_category_id']} name={['profile', 'platform_product_category_id']}
label="产品类目" label="产品类目"
@@ -61,6 +61,10 @@ Headers:`Idempotency-Key` 必填,页头平台由 header 指定,body 为空
"base_source_snapshot_revision": 2, "base_source_snapshot_revision": 2,
"has_source_updates": true "has_source_updates": true
}, },
"captured_category_match": {
"status": "ambiguous", "root_category_name": "儿童用品", "type_name": "小雕塑",
"candidates": [{ "id": "uuid", "name": "小雕塑", "path": "儿童用品 › … › 小雕塑", "external_category_id": "123", "external_type_id": "456" }]
},
"ai_input": { "ai_input": {
"source_material": {}, "default_physical_spec": {}, "default_package_spec": {}, "source_material": {}, "default_physical_spec": {}, "default_package_spec": {},
"variants": [{ "variant_id": "uuid", "display_name": "黑色 / M", "specification_values": [], "variants": [{ "variant_id": "uuid", "display_name": "黑色 / M", "specification_values": [],
@@ -76,6 +80,8 @@ Headers:`Idempotency-Key` 必填,页头平台由 header 指定,body 为空
`source_material` 是供页面展示和 AI 使用的**有效来源素材**,由当前 `collection_items.normalized_payload` 的最新规范化值与用户明确修改过的顶层字段合并而成。未修改字段随重复采集自动更新;已修改字段继续保留用户值。`source_material_state.latest` 保留本次最新规范化来源,`overridden_fields` 表示用户覆盖字段,`changed_fields` 表示复采后与用户稿不同的覆盖字段,页面可据此提示、逐项恢复或全部采用最新采集值。原始 `normalized_payload` 与 `raw_evidence` 始终作为采集证据保留,不因编辑而改变。 `source_material` 是供页面展示和 AI 使用的**有效来源素材**,由当前 `collection_items.normalized_payload` 的最新规范化值与用户明确修改过的顶层字段合并而成。未修改字段随重复采集自动更新;已修改字段继续保留用户值。`source_material_state.latest` 保留本次最新规范化来源,`overridden_fields` 表示用户覆盖字段,`changed_fields` 表示复采后与用户稿不同的覆盖字段,页面可据此提示、逐项恢复或全部采用最新采集值。原始 `normalized_payload` 与 `raw_evidence` 始终作为采集证据保留,不因编辑而改变。
`captured_category_match` 仅对 Ozon 来源返回自动类目判定结果。服务端只使用公开详情页的**一级**面包屑和结构化 `Type_0` 商品类型,在当前平台目录的活动可发布叶子中精确匹配;公开页后二、三级导航不参与判定。`matched` 时服务端已自动写入 `source_capture`;`ambiguous` 时前端展示 `candidates` 的完整 Atlas 路径,用户确认后按普通手选类目保存为 `manual`;`unmatched`、`unavailable` 均不得自动选择。
`ai_input` 是后续 AI 任务的服务端输入视图:由上述有效 `source_material`、父商品默认实物/包装规格,以及每个 SKU 合并继承后的有效规格与规格轴组成。 `ai_input` 是后续 AI 任务的服务端输入视图:由上述有效 `source_material`、父商品默认实物/包装规格,以及每个 SKU 合并继承后的有效规格与规格轴组成。
## 2. 原子保存草稿与 SKU 规则 ## 2. 原子保存草稿与 SKU 规则
+15
View File
@@ -32,6 +32,20 @@ export type ProductAiSourceMaterialState = {
has_source_updates: boolean; has_source_updates: boolean;
}; };
/** Ozon 来源一级类目和商品类型在平台目录中的只读匹配结果。 */
export type CapturedCategoryMatch = {
status: 'unavailable' | 'unmatched' | 'matched' | 'ambiguous';
root_category_name: string | null;
type_name: string | null;
candidates: Array<{
id: string;
name: string;
path: string;
external_category_id: string;
external_type_id: string;
}>;
};
export type ProductAiInput = { export type ProductAiInput = {
source_material: ProductAiSourceMaterial; source_material: ProductAiSourceMaterial;
default_physical_spec: PhysicalSpec; default_physical_spec: PhysicalSpec;
@@ -266,6 +280,7 @@ export type ProductEditorSnapshot = {
}>; }>;
source_material: ProductAiSourceMaterial; source_material: ProductAiSourceMaterial;
source_material_state: ProductAiSourceMaterialState; source_material_state: ProductAiSourceMaterialState;
captured_category_match: CapturedCategoryMatch;
ai_input: ProductAiInput; ai_input: ProductAiInput;
warnings: Array<{ code: string; message: string }>; warnings: Array<{ code: string; message: string }>;
}; };
File diff suppressed because one or more lines are too long