feat: 初始化项目,项目需求、方案文档
This commit is contained in:
+17
@@ -0,0 +1,17 @@
|
|||||||
|
# Secrets and local configuration
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Operating-system and editor files
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Common future runtime/build artifacts
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Atlas Seller Kit
|
||||||
|
|
||||||
|
Atlas Seller Kit 是面向多用户、多门店、多平台与多国家市场的云端卖家商品工作台。首个编辑工作流是「商品 AI 编辑页」:以商品(SPU)和可售 SKU 管理商品信息、媒体、定价与后续平台发布。
|
||||||
|
|
||||||
|
本仓库是从 `ozon-seller-kit` 独立出来的新产品,不继承其本地 SQLite、无鉴权或单店铺的运行假设,也不复制其旧页面代码。
|
||||||
|
|
||||||
|
## 当前状态
|
||||||
|
|
||||||
|
仅完成产品与架构设计,尚未开始应用代码开发。开始新 AI 对话或开发前,请先阅读 [项目交接](./docs/project/HANDOFF.md),再阅读 [文档导航](./docs/README.md)。
|
||||||
|
|
||||||
|
## 设计目标
|
||||||
|
|
||||||
|
- 云端部署,使用 PostgreSQL 而不是本地数据库;
|
||||||
|
- 用户 → 工作空间 → 门店的多租户隔离;
|
||||||
|
- 门店可接入不同平台、国家、语言和币种;
|
||||||
|
- 商品级通用内容与 SKU 级可售数据清晰分离;
|
||||||
|
- 一 SKU 对应一个可独立发布的平台 offer;
|
||||||
|
- AI 生成、媒体处理和发布采用可恢复的后台任务。
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Atlas Seller Kit 文档
|
||||||
|
|
||||||
|
> 先确认产品需求,再进入数据库、接口和开发设计。本目录不代表已实施功能。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
| 类型 | 位置 | 内容 |
|
||||||
|
|---|---|---|
|
||||||
|
| 项目 | [`project/`](./project/) | 产品定位、交接与已确认的长期约束 |
|
||||||
|
| 产品需求 | [`requirements/`](./requirements/) | 页面、流程、字段和待确认事项 |
|
||||||
|
| 基础架构 | [`architecture/`](./architecture/) | 多租户、平台、门店、水印、物流和定价的数据设计及 DDL |
|
||||||
|
| 商品领域设计 | [`product/`](./product/) | 已有的多 SKU 商品编辑页、领域模型与 API 草案 |
|
||||||
|
| 外部平台资料 | [`integrations/`](./integrations/) | Ozon Seller API 的整理资料与适配依据 |
|
||||||
|
|
||||||
|
## 当前阅读顺序
|
||||||
|
|
||||||
|
1. [`project/HANDOFF.md`](./project/HANDOFF.md):项目边界和已经确认的商品规则。
|
||||||
|
2. [`requirements/account-store-watermark.md`](./requirements/account-store-watermark.md):账户、门店和水印需求。
|
||||||
|
3. [`requirements/pricing-tool.md`](./requirements/pricing-tool.md):独立试算、SKU 定价方案和物流规则需求。
|
||||||
|
4. [`requirements/cel-ozon-rfbs-rates-review.md`](./requirements/cel-ozon-rfbs-rates-review.md):从 CEL Ozon-rFBS 资费表提取的待审核规则。
|
||||||
|
5. [`architecture/foundation-data-model.md`](./architecture/foundation-data-model.md):基础模块数据模型和 PostgreSQL DDL。
|
||||||
|
6. [`architecture/technology-and-deployment.md`](./architecture/technology-and-deployment.md):已确认的 NestJS、PostgreSQL、Redis/BullMQ、MinIO、七牛 Kodo 与部署策略。
|
||||||
|
7. 需求确认后,才创建对应的接口和开发方案文档。
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# 云端基础模块:数据模型与数据库边界
|
||||||
|
|
||||||
|
> 状态:设计稿。数据库为 PostgreSQL;配套可执行 DDL 见 [`sql/001_foundation.sql`](./sql/001_foundation.sql)。本阶段只产出设计和 SQL,不连接、不创建实际数据库。
|
||||||
|
|
||||||
|
## 1. 核心边界
|
||||||
|
|
||||||
|
```text
|
||||||
|
账户 User ── Workspace(租户)── Membership
|
||||||
|
├─ Store ── Store secret / Warehouse / Watermark binding
|
||||||
|
├─ Watermark template ── versions / uploaded asset
|
||||||
|
└─ Pricing policy ── Quote / quote options
|
||||||
|
|
||||||
|
Platform(全局) ── Platform product category ── Platform commission category mapping
|
||||||
|
└─ Commission price tiers
|
||||||
|
|
||||||
|
Logistics provider(全局) ── Rate card ── Product type × transport method × rate rule
|
||||||
|
```
|
||||||
|
|
||||||
|
- **工作空间即租户**:新账户创建一个工作空间,创建者是 `owner`。用户、工作空间、成员关系分离,以支持未来多人协作和一个用户管理多个工作空间。
|
||||||
|
- **平台与当前店铺是全局上下文**:店铺属于“工作空间 + 平台”;每个平台最多一个默认店铺。所有门店业务数据均携带 `workspace_id`。
|
||||||
|
- **两套类目均强绑定平台**:平台商品类目来自 Ozon 等平台的商品分类;平台佣金类目来自该平台的佣金规则树。它们在同一平台内独立维护,并由版本化映射关联;SQL 使用 `platform_id + category_id` 的复合外键阻止跨平台映射,绝不按名称或 ID 猜测。
|
||||||
|
- **CEL 价表是全局规则数据**:由后台维护版本和生效期;报价只引用确定版本。首期仅 Ozon-rFBS + CEL,香港空运不录入首期规则。
|
||||||
|
- **报价是快照**:定价方案可以变,CEL/佣金规则可以更新,但已确认 SKU 报价始终保留当时输入、费率、规则版本和费用拆分。
|
||||||
|
|
||||||
|
## 2. 关键表的产品映射
|
||||||
|
|
||||||
|
| 领域 | 表 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 账户与租户 | `users`、`workspaces`、`workspace_memberships`、`user_sessions` | 手机号密码登录;短信验证/风控字段预留但一期不启用。 |
|
||||||
|
| 平台与门店 | `platforms`、`currencies`、`platform_currency_support`、`stores`、`store_secrets`、`store_warehouses` | API Key 单独加密保存;密钥过期时间、商品限额无数据时使用 `NULL`,界面显示 `—`。 |
|
||||||
|
| 水印 | `media_assets`、`watermark_fonts`、`watermark_templates`、`watermark_template_versions`、`store_watermark_bindings` | 店铺绑定一个当前模板;发布任务未来固化模板版本,而不修改原图。 |
|
||||||
|
| 类目佣金 | `platform_product_categories`、`commission_categories`、`commission_category_rules`、`platform_product_category_commission_mappings` | 平台商品类目和平台佣金类目都强绑定同一平台;佣金按销售价 RUB 阶梯计费,抓取数据以 `competitor_capture` 来源和待审核版本录入。 |
|
||||||
|
| 跨境物流 | `logistics_providers`、`logistics_rate_cards`、`logistics_product_types`、`logistics_transport_methods`、`logistics_rate_rules` | CEL 产品类型、运输方式、货值/重量/尺寸/抛重规则、费率和版本分开保存。 |
|
||||||
|
| 定价 | `pricing_policies`、`pricing_policy_transport_methods`、`pricing_quotes`、`pricing_quote_options` | 店铺默认方案、SKU 单次临时选择、所有候选运输方式结果与最终确认结果。 |
|
||||||
|
|
||||||
|
## 3. 必须由服务端保证的规则
|
||||||
|
|
||||||
|
1. 在每一个请求事务开始后,先验证当前用户的工作空间成员身份,再执行 `SET LOCAL app.workspace_id = '<uuid>'`;PostgreSQL RLS 作为第二道隔离。
|
||||||
|
2. `store_secrets.secret_ciphertext` 只保存信封加密后的密文、nonce 和密钥版本。不得写日志、返回 API 响应或发往浏览器缓存。
|
||||||
|
3. 一个工作空间在同一平台只有一个活动默认店铺;切换默认店铺必须在同一事务内完成。
|
||||||
|
4. 定价引擎枚举“佣金档位 × CEL 产品类型 × 运输方式”,回验最终销售价的 RUB 佣金档和 CNY 商品货值档;不能沿用旧项目的固定 12% / 18% 佣金。
|
||||||
|
5. `pricing_quotes.product_variant_id` 暂为逻辑外键:商品/SKU DDL 落地时会补为指向 `product_variants` 的真实外键。报价本身可在没有 SKU 的“即时试算”中存在。
|
||||||
|
|
||||||
|
## 4. 初始数据与迁移顺序
|
||||||
|
|
||||||
|
1. 执行 `001_foundation.sql`。
|
||||||
|
2. 初始化 `OZON` 平台、`CNY` / `RUB` 币种和平台币种支持关系。
|
||||||
|
3. 建立 Ozon 平台商品类目和 Ozon 平台佣金类目树,再人工审核并导入同平台映射与阶梯佣金规则。
|
||||||
|
4. 将已 Review 的 CEL Ozon-rFBS 规则录入一个 `draft` rate card,用真实 CEL 报价复核后发布为 `active`。
|
||||||
|
5. 商品/SKU 模块落地后,增加 `product_variants` 外键并把 SKU “定价”按钮接到 `pricing_quotes`。
|
||||||
|
|
||||||
|
## 5. 仍不在此 DDL 内的内容
|
||||||
|
|
||||||
|
- 短信发送、验证码、风控供应商和密码找回流程;
|
||||||
|
- Ozon API 的实际同步 Worker、密钥轮换 Worker 和对象存储上传;
|
||||||
|
- 商品/SPU/SKU、媒体处理任务与真正的上架任务表;
|
||||||
|
- 平台/物流/佣金规则的后台管理 UI 与导入程序。
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
-- Atlas Seller Kit: cloud foundation schema
|
||||||
|
-- PostgreSQL 15+ recommended. Run through a migration tool, not from the browser.
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS citext;
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS atlas;
|
||||||
|
SET search_path TO atlas, public;
|
||||||
|
|
||||||
|
CREATE TYPE membership_role AS ENUM ('owner', 'admin', 'editor', 'viewer');
|
||||||
|
CREATE TYPE account_status AS ENUM ('active', 'disabled', 'pending_review');
|
||||||
|
CREATE TYPE platform_status AS ENUM ('active', 'disabled');
|
||||||
|
CREATE TYPE store_status AS ENUM ('verifying', 'active', 'credential_invalid', 'sync_error', 'disabled', 'deleted');
|
||||||
|
CREATE TYPE credential_status AS ENUM ('unknown', 'valid', 'invalid', 'expired', 'rotation_required');
|
||||||
|
CREATE TYPE secret_kind AS ENUM ('seller_api_key', 'browser_cookie');
|
||||||
|
CREATE TYPE watermark_kind AS ENUM ('text', 'image');
|
||||||
|
CREATE TYPE watermark_position AS ENUM ('top_left', 'top_center', 'top_right', 'middle_left', 'center', 'middle_right', 'bottom_left', 'bottom_center', 'bottom_right');
|
||||||
|
CREATE TYPE record_status AS ENUM ('draft', 'active', 'archived', 'superseded');
|
||||||
|
CREATE TYPE rule_source AS ENUM ('manual_rule', 'competitor_capture', 'platform_sync', 'product_observed');
|
||||||
|
CREATE TYPE fulfillment_mode AS ENUM ('rfbs', 'fbs', 'fbo', 'fbp', 'unknown');
|
||||||
|
CREATE TYPE billing_weight_basis AS ENUM ('actual_only', 'max_actual_volume', 'conditional_volume');
|
||||||
|
CREATE TYPE quote_status AS ENUM ('draft', 'calculated', 'confirmed', 'invalid', 'superseded');
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
phone_e164 TEXT NOT NULL UNIQUE CHECK (phone_e164 ~ '^\\+[1-9][0-9]{5,14}$'),
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
display_name TEXT,
|
||||||
|
status account_status NOT NULL DEFAULT 'active',
|
||||||
|
phone_verified_at TIMESTAMPTZ,
|
||||||
|
last_login_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE workspaces (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug CITEXT NOT NULL UNIQUE,
|
||||||
|
status record_status NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE workspace_memberships (
|
||||||
|
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||||
|
role membership_role NOT NULL DEFAULT 'editor',
|
||||||
|
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (workspace_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
refresh_token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
ip_hash TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CHECK (expires_at > created_at)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE platforms (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
code CITEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
status platform_status NOT NULL DEFAULT 'active',
|
||||||
|
capabilities JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE currencies (
|
||||||
|
code CHAR(3) PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
decimal_places SMALLINT NOT NULL DEFAULT 2 CHECK (decimal_places BETWEEN 0 AND 6),
|
||||||
|
is_enabled BOOLEAN NOT NULL DEFAULT true
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE platform_currency_support (
|
||||||
|
platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE CASCADE,
|
||||||
|
currency_code CHAR(3) NOT NULL REFERENCES currencies(code),
|
||||||
|
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
PRIMARY KEY (platform_id, currency_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE stores (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||||
|
platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE RESTRICT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
platform_client_id TEXT NOT NULL,
|
||||||
|
currency_code CHAR(3) NOT NULL REFERENCES currencies(code),
|
||||||
|
is_default BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
note TEXT,
|
||||||
|
status store_status NOT NULL DEFAULT 'verifying',
|
||||||
|
credential_status credential_status NOT NULL DEFAULT 'unknown',
|
||||||
|
credential_checked_at TIMESTAMPTZ,
|
||||||
|
api_key_expires_at TIMESTAMPTZ,
|
||||||
|
product_limit INTEGER CHECK (product_limit IS NULL OR product_limit >= 0),
|
||||||
|
product_limit_checked_at TIMESTAMPTZ,
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (workspace_id, id),
|
||||||
|
UNIQUE (workspace_id, id, platform_id),
|
||||||
|
UNIQUE (workspace_id, platform_id, platform_client_id)
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX ux_stores_one_default_per_platform
|
||||||
|
ON stores (workspace_id, platform_id)
|
||||||
|
WHERE is_default AND deleted_at IS NULL;
|
||||||
|
CREATE INDEX ix_stores_workspace_platform ON stores (workspace_id, platform_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE store_secrets (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id UUID NOT NULL,
|
||||||
|
store_id UUID NOT NULL,
|
||||||
|
kind secret_kind NOT NULL,
|
||||||
|
secret_ciphertext BYTEA NOT NULL,
|
||||||
|
secret_nonce BYTEA NOT NULL,
|
||||||
|
encryption_key_version TEXT NOT NULL,
|
||||||
|
fingerprint TEXT NOT NULL,
|
||||||
|
status credential_status NOT NULL DEFAULT 'unknown',
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
last_verified_at TIMESTAMPTZ,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
FOREIGN KEY (workspace_id, store_id) REFERENCES stores(workspace_id, id) ON DELETE CASCADE,
|
||||||
|
UNIQUE (workspace_id, store_id, kind, fingerprint)
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX ux_store_secrets_one_current_kind
|
||||||
|
ON store_secrets (workspace_id, store_id, kind)
|
||||||
|
WHERE revoked_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE store_warehouses (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id UUID NOT NULL,
|
||||||
|
store_id UUID NOT NULL,
|
||||||
|
platform_warehouse_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
fulfillment fulfillment_mode NOT NULL DEFAULT 'unknown',
|
||||||
|
is_rfbs BOOLEAN,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
synced_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
FOREIGN KEY (workspace_id, store_id) REFERENCES stores(workspace_id, id) ON DELETE CASCADE,
|
||||||
|
UNIQUE (workspace_id, store_id, platform_warehouse_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE media_assets (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||||
|
object_key TEXT NOT NULL,
|
||||||
|
content_type TEXT NOT NULL,
|
||||||
|
byte_size BIGINT NOT NULL CHECK (byte_size > 0),
|
||||||
|
checksum_sha256 TEXT NOT NULL,
|
||||||
|
width_px INTEGER CHECK (width_px IS NULL OR width_px > 0),
|
||||||
|
height_px INTEGER CHECK (height_px IS NULL OR height_px > 0),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (workspace_id, id),
|
||||||
|
UNIQUE (workspace_id, object_key),
|
||||||
|
UNIQUE (workspace_id, checksum_sha256)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE watermark_fonts (
|
||||||
|
code CITEXT PRIMARY KEY,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
status record_status NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE watermark_templates (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
kind watermark_kind NOT NULL,
|
||||||
|
current_version INTEGER NOT NULL DEFAULT 1 CHECK (current_version > 0),
|
||||||
|
status record_status NOT NULL DEFAULT 'active',
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (workspace_id, id),
|
||||||
|
UNIQUE (workspace_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE watermark_template_versions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id UUID NOT NULL,
|
||||||
|
template_id UUID NOT NULL,
|
||||||
|
version INTEGER NOT NULL CHECK (version > 0),
|
||||||
|
kind watermark_kind NOT NULL,
|
||||||
|
text_content TEXT,
|
||||||
|
image_asset_id UUID,
|
||||||
|
font_code CITEXT REFERENCES watermark_fonts(code),
|
||||||
|
font_size_px SMALLINT CHECK (font_size_px BETWEEN 12 AND 60),
|
||||||
|
opacity NUMERIC(5,4) NOT NULL CHECK (opacity BETWEEN 0 AND 1),
|
||||||
|
scale_ratio NUMERIC(7,4) CHECK (scale_ratio > 0),
|
||||||
|
position watermark_position NOT NULL,
|
||||||
|
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
FOREIGN KEY (workspace_id, template_id) REFERENCES watermark_templates(workspace_id, id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (workspace_id, image_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
|
||||||
|
UNIQUE (workspace_id, template_id, version),
|
||||||
|
CHECK (
|
||||||
|
(kind = 'text' AND text_content IS NOT NULL AND font_code IS NOT NULL AND font_size_px IS NOT NULL AND image_asset_id IS NULL)
|
||||||
|
OR (kind = 'image' AND image_asset_id IS NOT NULL AND scale_ratio IS NOT NULL AND text_content IS NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE store_watermark_bindings (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id UUID NOT NULL,
|
||||||
|
store_id UUID NOT NULL,
|
||||||
|
template_id UUID NOT NULL,
|
||||||
|
bound_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
bound_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
unbound_at TIMESTAMPTZ,
|
||||||
|
FOREIGN KEY (workspace_id, store_id) REFERENCES stores(workspace_id, id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (workspace_id, template_id) REFERENCES watermark_templates(workspace_id, id) ON DELETE RESTRICT,
|
||||||
|
CHECK (unbound_at IS NULL OR unbound_at >= bound_at)
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX ux_store_watermark_one_active
|
||||||
|
ON store_watermark_bindings (workspace_id, store_id)
|
||||||
|
WHERE unbound_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE platform_product_categories (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE CASCADE,
|
||||||
|
parent_id UUID REFERENCES platform_product_categories(id) ON DELETE RESTRICT,
|
||||||
|
external_category_id TEXT NOT NULL,
|
||||||
|
external_type_id TEXT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
source_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
synced_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (platform_id, id),
|
||||||
|
UNIQUE (platform_id, external_category_id, external_type_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE commission_categories (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE CASCADE,
|
||||||
|
parent_id UUID REFERENCES commission_categories(id) ON DELETE RESTRICT,
|
||||||
|
source_category_key TEXT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
source rule_source NOT NULL,
|
||||||
|
status record_status NOT NULL DEFAULT 'draft',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (platform_id, id),
|
||||||
|
UNIQUE (platform_id, path)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE commission_category_rules (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
commission_category_id UUID NOT NULL REFERENCES commission_categories(id) ON DELETE CASCADE,
|
||||||
|
fulfillment fulfillment_mode NOT NULL DEFAULT 'unknown',
|
||||||
|
market_code TEXT NOT NULL DEFAULT 'RU',
|
||||||
|
currency_code CHAR(3) NOT NULL REFERENCES currencies(code),
|
||||||
|
min_price NUMERIC(18,4),
|
||||||
|
max_price NUMERIC(18,4),
|
||||||
|
min_inclusive BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
max_inclusive BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
commission_rate NUMERIC(7,6) NOT NULL CHECK (commission_rate >= 0 AND commission_rate < 1),
|
||||||
|
source rule_source NOT NULL,
|
||||||
|
version TEXT NOT NULL,
|
||||||
|
status record_status NOT NULL DEFAULT 'draft',
|
||||||
|
effective_from TIMESTAMPTZ NOT NULL,
|
||||||
|
effective_to TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CHECK (min_price IS NULL OR min_price >= 0),
|
||||||
|
CHECK (max_price IS NULL OR max_price >= 0),
|
||||||
|
CHECK (min_price IS NULL OR max_price IS NULL OR min_price <= max_price),
|
||||||
|
CHECK (effective_to IS NULL OR effective_to > effective_from)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE platform_product_category_commission_mappings (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE CASCADE,
|
||||||
|
platform_product_category_id UUID NOT NULL,
|
||||||
|
commission_category_id UUID NOT NULL,
|
||||||
|
fulfillment fulfillment_mode NOT NULL DEFAULT 'unknown',
|
||||||
|
market_code TEXT NOT NULL DEFAULT 'RU',
|
||||||
|
priority SMALLINT NOT NULL DEFAULT 100 CHECK (priority >= 0),
|
||||||
|
source rule_source NOT NULL DEFAULT 'manual_rule',
|
||||||
|
status record_status NOT NULL DEFAULT 'draft',
|
||||||
|
effective_from TIMESTAMPTZ NOT NULL,
|
||||||
|
effective_to TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
FOREIGN KEY (platform_id, platform_product_category_id)
|
||||||
|
REFERENCES platform_product_categories(platform_id, id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (platform_id, commission_category_id)
|
||||||
|
REFERENCES commission_categories(platform_id, id) ON DELETE RESTRICT,
|
||||||
|
CHECK (effective_to IS NULL OR effective_to > effective_from)
|
||||||
|
);
|
||||||
|
CREATE INDEX ix_category_commission_mapping_lookup
|
||||||
|
ON platform_product_category_commission_mappings (platform_product_category_id, fulfillment, market_code, status, effective_from DESC, priority);
|
||||||
|
|
||||||
|
CREATE TABLE logistics_providers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
code CITEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
status record_status NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE logistics_rate_cards (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
provider_id UUID NOT NULL REFERENCES logistics_providers(id) ON DELETE RESTRICT,
|
||||||
|
platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE RESTRICT,
|
||||||
|
fulfillment fulfillment_mode NOT NULL,
|
||||||
|
market_code TEXT NOT NULL DEFAULT 'RU',
|
||||||
|
version TEXT NOT NULL,
|
||||||
|
source_name TEXT NOT NULL,
|
||||||
|
source_uri TEXT,
|
||||||
|
status record_status NOT NULL DEFAULT 'draft',
|
||||||
|
effective_from TIMESTAMPTZ NOT NULL,
|
||||||
|
effective_to TIMESTAMPTZ,
|
||||||
|
reviewed_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
reviewed_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (platform_id, id),
|
||||||
|
UNIQUE (provider_id, platform_id, fulfillment, market_code, version),
|
||||||
|
CHECK (effective_to IS NULL OR effective_to > effective_from)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE logistics_product_types (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
rate_card_id UUID NOT NULL REFERENCES logistics_rate_cards(id) ON DELETE CASCADE,
|
||||||
|
code CITEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
merchandise_currency_code CHAR(3) NOT NULL REFERENCES currencies(code),
|
||||||
|
min_merchandise_value NUMERIC(18,4),
|
||||||
|
max_merchandise_value NUMERIC(18,4),
|
||||||
|
min_value_inclusive BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
max_value_inclusive BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
min_actual_weight_kg NUMERIC(12,3) NOT NULL DEFAULT 0.001 CHECK (min_actual_weight_kg >= 0.001),
|
||||||
|
max_actual_weight_kg NUMERIC(12,3),
|
||||||
|
max_sum_dimensions_cm NUMERIC(12,3),
|
||||||
|
max_longest_side_cm NUMERIC(12,3),
|
||||||
|
max_second_longest_side_cm NUMERIC(12,3),
|
||||||
|
billing_basis billing_weight_basis NOT NULL,
|
||||||
|
volume_divisor NUMERIC(12,3),
|
||||||
|
volume_condition_sum_gt_cm NUMERIC(12,3),
|
||||||
|
volume_round_scale SMALLINT CHECK (volume_round_scale BETWEEN 0 AND 6),
|
||||||
|
billing_round_scale SMALLINT CHECK (billing_round_scale BETWEEN 0 AND 6),
|
||||||
|
max_volume_weight_kg NUMERIC(12,3),
|
||||||
|
volume_limit_round_scale SMALLINT CHECK (volume_limit_round_scale BETWEEN 0 AND 6),
|
||||||
|
return_policy JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (rate_card_id, code),
|
||||||
|
CHECK (max_merchandise_value IS NULL OR max_merchandise_value >= 0),
|
||||||
|
CHECK (min_merchandise_value IS NULL OR min_merchandise_value >= 0),
|
||||||
|
CHECK (min_merchandise_value IS NULL OR max_merchandise_value IS NULL OR min_merchandise_value <= max_merchandise_value),
|
||||||
|
CHECK (max_actual_weight_kg IS NULL OR max_actual_weight_kg >= min_actual_weight_kg),
|
||||||
|
CHECK ((billing_basis = 'actual_only' AND volume_divisor IS NULL) OR (billing_basis <> 'actual_only' AND volume_divisor IS NOT NULL))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE logistics_transport_methods (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
rate_card_id UUID NOT NULL REFERENCES logistics_rate_cards(id) ON DELETE CASCADE,
|
||||||
|
code CITEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
speed_label TEXT,
|
||||||
|
min_transit_days SMALLINT CHECK (min_transit_days >= 0),
|
||||||
|
max_transit_days SMALLINT CHECK (max_transit_days >= min_transit_days),
|
||||||
|
status record_status NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (rate_card_id, code)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE logistics_rate_rules (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
product_type_id UUID NOT NULL REFERENCES logistics_product_types(id) ON DELETE CASCADE,
|
||||||
|
transport_method_id UUID NOT NULL REFERENCES logistics_transport_methods(id) ON DELETE CASCADE,
|
||||||
|
fixed_fee_cny NUMERIC(18,4) NOT NULL DEFAULT 0 CHECK (fixed_fee_cny >= 0),
|
||||||
|
per_billable_kg_fee_cny NUMERIC(18,4) NOT NULL CHECK (per_billable_kg_fee_cny >= 0),
|
||||||
|
min_charge_cny NUMERIC(18,4) CHECK (min_charge_cny IS NULL OR min_charge_cny >= 0),
|
||||||
|
surcharge_rules JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
status record_status NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (product_type_id, transport_method_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE pricing_policies (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id UUID NOT NULL,
|
||||||
|
store_id UUID NOT NULL,
|
||||||
|
platform_id UUID NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
note TEXT,
|
||||||
|
status record_status NOT NULL DEFAULT 'active',
|
||||||
|
is_default BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
rate_card_id UUID NOT NULL,
|
||||||
|
default_transport_method_id UUID REFERENCES logistics_transport_methods(id) ON DELETE RESTRICT,
|
||||||
|
domestic_shipping_and_label_fee_cny NUMERIC(18,4) NOT NULL DEFAULT 0 CHECK (domestic_shipping_and_label_fee_cny >= 0),
|
||||||
|
ad_rate NUMERIC(7,6) NOT NULL DEFAULT 0 CHECK (ad_rate >= 0 AND ad_rate < 1),
|
||||||
|
last_mile_rate NUMERIC(7,6) NOT NULL DEFAULT 0 CHECK (last_mile_rate >= 0 AND last_mile_rate < 1),
|
||||||
|
withdrawal_rate NUMERIC(7,6) NOT NULL DEFAULT 0 CHECK (withdrawal_rate >= 0 AND withdrawal_rate < 1),
|
||||||
|
other_rate NUMERIC(7,6) NOT NULL DEFAULT 0 CHECK (other_rate >= 0 AND other_rate < 1),
|
||||||
|
desired_markup_rate NUMERIC(7,6) NOT NULL DEFAULT 0 CHECK (desired_markup_rate >= 0),
|
||||||
|
list_price_discount_rate NUMERIC(7,6) NOT NULL DEFAULT 0 CHECK (list_price_discount_rate >= 0 AND list_price_discount_rate < 1),
|
||||||
|
fx_strategy TEXT NOT NULL DEFAULT 'system' CHECK (fx_strategy IN ('system', 'fixed', 'override_allowed')),
|
||||||
|
fixed_cny_to_rub_rate NUMERIC(18,8),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
FOREIGN KEY (workspace_id, store_id, platform_id) REFERENCES stores(workspace_id, id, platform_id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (platform_id, rate_card_id) REFERENCES logistics_rate_cards(platform_id, id) ON DELETE RESTRICT,
|
||||||
|
UNIQUE (workspace_id, id),
|
||||||
|
UNIQUE (workspace_id, id, store_id, platform_id),
|
||||||
|
UNIQUE (workspace_id, store_id, name),
|
||||||
|
CHECK ((fx_strategy = 'fixed' AND fixed_cny_to_rub_rate > 0) OR (fx_strategy <> 'fixed' AND fixed_cny_to_rub_rate IS NULL))
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX ux_pricing_policies_one_default
|
||||||
|
ON pricing_policies (workspace_id, store_id)
|
||||||
|
WHERE is_default AND status = 'active';
|
||||||
|
|
||||||
|
CREATE TABLE pricing_policy_transport_methods (
|
||||||
|
workspace_id UUID NOT NULL,
|
||||||
|
pricing_policy_id UUID NOT NULL,
|
||||||
|
transport_method_id UUID NOT NULL REFERENCES logistics_transport_methods(id) ON DELETE RESTRICT,
|
||||||
|
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
sort_order SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (workspace_id, pricing_policy_id, transport_method_id),
|
||||||
|
FOREIGN KEY (workspace_id, pricing_policy_id) REFERENCES pricing_policies(workspace_id, id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE pricing_quotes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||||
|
store_id UUID NOT NULL,
|
||||||
|
platform_id UUID NOT NULL,
|
||||||
|
pricing_policy_id UUID,
|
||||||
|
product_variant_id UUID, -- Logical FK; add real FK in the product/SKU migration.
|
||||||
|
platform_product_category_id UUID,
|
||||||
|
fulfillment fulfillment_mode NOT NULL,
|
||||||
|
purchase_cost_cny NUMERIC(18,4) NOT NULL CHECK (purchase_cost_cny >= 0),
|
||||||
|
package_weight_kg NUMERIC(12,3) NOT NULL CHECK (package_weight_kg >= 0.001),
|
||||||
|
package_length_cm NUMERIC(12,3) NOT NULL CHECK (package_length_cm > 0),
|
||||||
|
package_width_cm NUMERIC(12,3) NOT NULL CHECK (package_width_cm > 0),
|
||||||
|
package_height_cm NUMERIC(12,3) NOT NULL CHECK (package_height_cm > 0),
|
||||||
|
fx_cny_to_rub NUMERIC(18,8) NOT NULL CHECK (fx_cny_to_rub > 0),
|
||||||
|
input_snapshot JSONB NOT NULL,
|
||||||
|
status quote_status NOT NULL DEFAULT 'draft',
|
||||||
|
selected_option_id UUID,
|
||||||
|
calculated_at TIMESTAMPTZ,
|
||||||
|
confirmed_at TIMESTAMPTZ,
|
||||||
|
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
FOREIGN KEY (workspace_id, store_id, platform_id) REFERENCES stores(workspace_id, id, platform_id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (workspace_id, pricing_policy_id, store_id, platform_id)
|
||||||
|
REFERENCES pricing_policies(workspace_id, id, store_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)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE pricing_quote_options (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id UUID NOT NULL,
|
||||||
|
quote_id UUID NOT NULL,
|
||||||
|
logistics_rate_rule_id UUID REFERENCES logistics_rate_rules(id) ON DELETE SET NULL,
|
||||||
|
commission_rule_id UUID REFERENCES commission_category_rules(id) ON DELETE SET NULL,
|
||||||
|
logistics_product_type_id UUID REFERENCES logistics_product_types(id) ON DELETE SET NULL,
|
||||||
|
transport_method_id UUID REFERENCES logistics_transport_methods(id) ON DELETE SET NULL,
|
||||||
|
merchandise_value_cny NUMERIC(18,4) NOT NULL CHECK (merchandise_value_cny >= 0),
|
||||||
|
selling_price_rub NUMERIC(18,4) NOT NULL CHECK (selling_price_rub >= 0),
|
||||||
|
list_price_rub NUMERIC(18,4) NOT NULL CHECK (list_price_rub >= selling_price_rub),
|
||||||
|
billable_weight_kg NUMERIC(12,3) NOT NULL CHECK (billable_weight_kg >= 0.001),
|
||||||
|
logistics_fee_cny NUMERIC(18,4) NOT NULL CHECK (logistics_fee_cny >= 0),
|
||||||
|
commission_fee_rub NUMERIC(18,4) NOT NULL CHECK (commission_fee_rub >= 0),
|
||||||
|
domestic_fee_cny NUMERIC(18,4) NOT NULL CHECK (domestic_fee_cny >= 0),
|
||||||
|
ad_fee_rub NUMERIC(18,4) NOT NULL CHECK (ad_fee_rub >= 0),
|
||||||
|
last_mile_fee_rub NUMERIC(18,4) NOT NULL CHECK (last_mile_fee_rub >= 0),
|
||||||
|
withdrawal_fee_rub NUMERIC(18,4) NOT NULL CHECK (withdrawal_fee_rub >= 0),
|
||||||
|
other_fee_rub NUMERIC(18,4) NOT NULL CHECK (other_fee_rub >= 0),
|
||||||
|
profit_rub NUMERIC(18,4) NOT NULL,
|
||||||
|
calculation_snapshot JSONB NOT NULL,
|
||||||
|
is_valid BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
validation_errors JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
FOREIGN KEY (workspace_id, quote_id) REFERENCES pricing_quotes(workspace_id, id) ON DELETE CASCADE,
|
||||||
|
UNIQUE (workspace_id, quote_id, transport_method_id, logistics_product_type_id, commission_rule_id)
|
||||||
|
);
|
||||||
|
ALTER TABLE pricing_quotes
|
||||||
|
ADD CONSTRAINT fk_pricing_quotes_selected_option
|
||||||
|
FOREIGN KEY (selected_option_id) REFERENCES pricing_quote_options(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Tenant isolation. The API must set app.workspace_id with SET LOCAL after authorization.
|
||||||
|
CREATE OR REPLACE FUNCTION app_current_workspace_id() RETURNS UUID
|
||||||
|
LANGUAGE SQL STABLE AS $$
|
||||||
|
SELECT NULLIF(current_setting('app.workspace_id', true), '')::UUID
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE table_name TEXT;
|
||||||
|
BEGIN
|
||||||
|
FOREACH table_name IN ARRAY ARRAY[
|
||||||
|
'stores', 'store_secrets', 'store_warehouses', 'media_assets',
|
||||||
|
'watermark_templates', 'watermark_template_versions', 'store_watermark_bindings',
|
||||||
|
'pricing_policies', 'pricing_policy_transport_methods', 'pricing_quotes', 'pricing_quote_options'
|
||||||
|
]
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER TABLE %I ENABLE 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 $$;
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# 技术栈、异步任务与部署方案
|
||||||
|
|
||||||
|
> 状态:已确认。本文定义首期技术选型和环境边界;具体 Docker Compose、CI/CD、密钥和云资源参数在实施前补充为运行手册。
|
||||||
|
|
||||||
|
## 1. 已确认技术选型
|
||||||
|
|
||||||
|
| 层级 | 选型 | 职责 |
|
||||||
|
|---|---|---|
|
||||||
|
| 前端 | React 19、TypeScript、Vite、Ant Design、React Router、Axios | 延续 `ozon-seller-kit/studio` 的前端技术路线,构建卖家工作台。 |
|
||||||
|
| 主后端 | NestJS、TypeScript | 认证、门店、商品/SKU、定价、平台适配、任务编排和 HTTP API。 |
|
||||||
|
| 关系数据库 | PostgreSQL | 业务事实来源:用户、隐藏工作空间、门店、商品、报价、任务记录和审计。 |
|
||||||
|
| 缓存与队列 | Redis、BullMQ | 待执行任务、延迟/重试、进度和限流;不作为业务事实来源。 |
|
||||||
|
| 对象存储(本地) | MinIO | 本地开发存放图片和文件,使用 S3 兼容接口。 |
|
||||||
|
| 对象存储(生产) | 七牛云 Kodo | 生产及共享测试环境存放图片和文件。Kodo 的基础 S3 兼容能力用于上传、下载、删除和短时访问链接。 |
|
||||||
|
| 运行与编排 | Docker、Docker Compose | 本地一键启动依赖;首期服务器部署 API、Worker、Redis 和反向代理。 |
|
||||||
|
|
||||||
|
不将旧项目的 FastAPI 服务作为新项目运行时依赖;旧项目的 Ozon 资料、计价规则、采集经验和前端技术路线可作为参考或选择性复用。
|
||||||
|
|
||||||
|
## 2. 服务边界
|
||||||
|
|
||||||
|
```text
|
||||||
|
Browser
|
||||||
|
│ HTTPS
|
||||||
|
▼
|
||||||
|
Reverse proxy / CDN
|
||||||
|
├─ Web:Vite 构建的静态前端
|
||||||
|
└─ NestJS API
|
||||||
|
├─ PostgreSQL:业务数据、任务状态、审计与规则快照
|
||||||
|
├─ Redis:BullMQ 队列与短期缓存
|
||||||
|
├─ Kodo / MinIO:原图、处理图、AI 图和导入文件
|
||||||
|
└─ BullMQ Worker:水印、AI、平台同步、上架与重试
|
||||||
|
```
|
||||||
|
|
||||||
|
API 进程只处理短请求、鉴权、事务和任务投递。耗时或会失败重试的工作必须由独立 Worker 执行,不能在 HTTP 请求内直接完成。
|
||||||
|
|
||||||
|
## 3. Redis 与 BullMQ 的使用规则
|
||||||
|
|
||||||
|
Redis 是高速的临时基础设施;BullMQ 使用 Redis 保存队列中的任务及其调度/重试信息。两者都不是商品、门店或任务最终结果的唯一存储位置。
|
||||||
|
|
||||||
|
| 任务 | API 的动作 | Worker 的动作 | PostgreSQL 应保存的内容 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 水印/图片处理 | 创建任务并投递队列 | 读取原图、处理、上传派生图 | 任务状态、输入图片、输出图片、错误、重试次数 |
|
||||||
|
| AI 生图/智能规划 | 创建任务并投递队列 | 调用模型、轮询或处理回调、保存结果 | 模型/提示词版本、输入、结果、费用/用量、人工确认状态 |
|
||||||
|
| Ozon 同步 | 创建同步任务 | 限速调用 API,分页拉取并更新数据 | 同步游标、开始/完成时间、错误和结果摘要 |
|
||||||
|
| SKU 发布 | 创建发布任务 | 按店铺限流发布、查询结果、失败重试 | 发布状态、平台任务 ID、请求/响应摘要和错误 |
|
||||||
|
|
||||||
|
实现要求:
|
||||||
|
|
||||||
|
1. 所有长任务先在 PostgreSQL 创建任务记录,再在事务提交后投递队列;后续实施时使用 outbox 模式避免“数据库已提交但任务未投递”的遗漏。
|
||||||
|
2. 任务必须幂等:相同 SKU 的一次发布任务重试,不得重复创建平台商品。
|
||||||
|
3. 重试必须有次数上限、指数退避和可见错误;Ozon 等第三方 API 需按店铺/API 限额控制并发。
|
||||||
|
4. Redis 可丢失或重建,Worker 应能根据 PostgreSQL 中的未完成任务恢复投递;业务结果不只存在于 Redis。
|
||||||
|
|
||||||
|
## 4. 对象存储策略
|
||||||
|
|
||||||
|
对象存储保存二进制文件;PostgreSQL 只保存对象键、文件哈希、MIME 类型、尺寸、归属关系和处理状态,不保存大图片的二进制内容。
|
||||||
|
|
||||||
|
### 4.1 概念
|
||||||
|
|
||||||
|
- **Bucket(存储桶)**:一个独立文件仓库,例如 `atlas-prod-assets`。
|
||||||
|
- **Object Key(对象键)**:桶内文件的唯一键,例如 `workspaces/{workspaceId}/products/{productId}/original/{assetId}.jpg`。
|
||||||
|
- **S3 兼容**:以 Bucket、Object Key、上传/下载/删除和预签名 URL 这一类通用 API 操作对象存储。MinIO 与七牛 Kodo 均可用于此模式;应用只依赖首期所需的基础能力。
|
||||||
|
|
||||||
|
### 4.2 文件分层与访问控制
|
||||||
|
|
||||||
|
建议对象键按业务归属分层:
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspaces/{workspaceId}/
|
||||||
|
watermarks/{assetId}/
|
||||||
|
products/{productId}/original/{assetId}
|
||||||
|
products/{productId}/processed/{assetId}
|
||||||
|
products/{productId}/ai-generated/{assetId}
|
||||||
|
imports/{taskId}/{filename}
|
||||||
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
1. Bucket 默认私有;用户原图、模板图、导入文件和 AI 结果不长期公开。
|
||||||
|
2. 前端上传使用服务端签发的短时上传凭证或预签名 URL;浏览器不保存永久 Kodo 密钥。
|
||||||
|
3. 前端预览/下载使用短时访问 URL,或由 API 受控转发。
|
||||||
|
4. Ozon 发布由 Worker 用受控服务端凭据读取文件;不因平台发布而把私有原图改为公共读。
|
||||||
|
5. 对象删除应先检查数据库引用;商品、水印软删除或保留期结束后,由后台清理任务物理删除。
|
||||||
|
|
||||||
|
### 4.3 本地、测试与生产
|
||||||
|
|
||||||
|
| 环境 | 对象存储 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| local | Docker 中的 MinIO | 每位开发者独立数据,可随时用种子数据重建。 |
|
||||||
|
| staging | 七牛 Kodo 独立 Bucket | 家/公司联调和验收使用,严禁与生产 Bucket 混用。 |
|
||||||
|
| production | 七牛 Kodo 独立 Bucket | 保存真实用户文件,凭据只在生产运行环境中配置。 |
|
||||||
|
|
||||||
|
首期不在普通单台云服务器自建生产 MinIO:自行承担磁盘冗余、备份、故障恢复、TLS、升级和监控,不能因“部署了 MinIO”而获得托管对象存储的可靠性。
|
||||||
|
|
||||||
|
## 5. 环境与数据库策略
|
||||||
|
|
||||||
|
| 环境 | PostgreSQL | 目的 |
|
||||||
|
|---|---|---|
|
||||||
|
| local | 本机 Docker PostgreSQL | 日常开发、迁移编写和自动化测试;每位开发者独立。 |
|
||||||
|
| staging | 云端独立 PostgreSQL | 家/公司共同联调、演示和测试店铺验证。 |
|
||||||
|
| production | 云托管 PostgreSQL | 真实业务数据;仅生产服务通过私网或受控网络访问。 |
|
||||||
|
|
||||||
|
不能把生产数据库作为开发数据库,也不建议所有日常开发直接共用同一云端数据库。数据库结构通过迁移文件保持一致;演示数据通过版本化 seed 脚本保持一致。迁移顺序必须是 local → staging → production,并在生产前备份和验证回滚方案。
|
||||||
|
|
||||||
|
## 6. AI 能力边界
|
||||||
|
|
||||||
|
首期 AI 智能规划、文案和生图均通过 Provider Adapter 调用云模型 API。NestJS 负责权限、上下文组装、结构化结果校验、任务状态和人工确认;模型不得直接改价、发布或修改门店数据。
|
||||||
|
|
||||||
|
若未来出现 GPU 推理、自托管扩散模型、大规模 OCR/CV、训练或微调,再增加独立 Python AI Worker。Python Worker 通过队列或内部 API 接收任务,主业务后端仍保持 NestJS,避免过早维护双语言的主业务系统。
|
||||||
|
|
||||||
|
## 7. Docker 与首期部署
|
||||||
|
|
||||||
|
本地 Docker Compose 至少编排:`web`、`api`、`worker`、`postgres`、`redis`、`minio`。前端开发也可单独使用 Vite 热更新,并将 `/api` 代理至本地 API。
|
||||||
|
|
||||||
|
首期云端 Docker Compose 编排:`api`、`worker`、`redis`、`reverse-proxy`;PostgreSQL 使用云托管实例,图片使用七牛 Kodo。每个环境使用独立 `.env`/密钥管理配置,密钥不提交 Git、不进入前端构建产物和日志。
|
||||||
|
|
||||||
|
在规模、可用性或多机部署出现明确瓶颈前,不引入 Kubernetes。
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
# Ozon Seller API 鉴权与基础
|
||||||
|
|
||||||
|
> 官方文档:https://docs.ozon.ru/api/seller/zh/#tag/Introduction
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 鉴权方式
|
||||||
|
|
||||||
|
Ozon Seller API 使用 **API Key 鉴权**(非 OAuth),每个请求需在请求头携带:
|
||||||
|
|
||||||
|
```http
|
||||||
|
Client-Id: <你的 Client ID>
|
||||||
|
Api-Key: <你的 API Key>
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 获取凭证
|
||||||
|
|
||||||
|
1. 登录 Ozon 卖家后台
|
||||||
|
2. 进入「设置」→「Seller API」
|
||||||
|
3. 点击「生成 API Key」
|
||||||
|
4. 选择权限级别:
|
||||||
|
- **只读**(Read):仅查询
|
||||||
|
- **读写**(Read & Write):查询 + 创建/更新商品
|
||||||
|
- **管理员**(Admin):所有权限
|
||||||
|
5. 保存 `Client-Id` 和 `Api-Key`(**Api-Key 仅显示一次**)
|
||||||
|
|
||||||
|
### 安全约束
|
||||||
|
|
||||||
|
- ⚠️ **Api-Key 等同密码**:泄露后任何人可操作你的店铺
|
||||||
|
- 🔒 **服务端存储**:加密落库(AES-GCM),前端永不传输/回显明文
|
||||||
|
- 🔄 **定期轮换**:建议每 90 天更换一次
|
||||||
|
- 🚫 **前端禁用**:插件/studio 不得持有店铺凭证,只能持有用户 token
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Base URL
|
||||||
|
|
||||||
|
```
|
||||||
|
https://api-seller.ozon.ru
|
||||||
|
```
|
||||||
|
|
||||||
|
所有接口路径都基于此 URL,例如:
|
||||||
|
```
|
||||||
|
POST https://api-seller.ozon.ru/v3/product/import
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 请求示例
|
||||||
|
|
||||||
|
### cURL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://api-seller.ozon.ru/v1/description-category/tree" \
|
||||||
|
-H "Client-Id: 123456" \
|
||||||
|
-H "Api-Key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"language":"RU"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python (httpx)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Client-Id": "123456",
|
||||||
|
"Api-Key": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"https://api-seller.ozon.ru/v1/description-category/tree",
|
||||||
|
headers=headers,
|
||||||
|
json={"language": "RU"}
|
||||||
|
)
|
||||||
|
data = resp.json()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 通用响应结构
|
||||||
|
|
||||||
|
### 成功响应(200/201)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": { /* 业务数据 */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
部分接口直接返回数组或对象,不包裹 `result`。
|
||||||
|
|
||||||
|
### 错误响应(4xx/5xx)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 400,
|
||||||
|
"message": "INVALID_ARGUMENT",
|
||||||
|
"details": [
|
||||||
|
{
|
||||||
|
"typeUrl": "type.googleapis.com/ozon.ValidationError",
|
||||||
|
"value": "..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
或简化版:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": "INVALID_PARAMETER",
|
||||||
|
"message": "offer_id is required"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 错误码
|
||||||
|
|
||||||
|
| HTTP 状态码 | 含义 | 常见原因 | 处理建议 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **400** | 参数错误 | 必填字段缺失 / 格式错误 / 枚举值非法 | 检查请求体字段,读 `details` 定位 |
|
||||||
|
| **401** | 未鉴权 | 请求头缺 `Client-Id` 或 `Api-Key` | 检查请求头 |
|
||||||
|
| **403** | 权限不足 | Api-Key 权限级别不够(如只读 key 调创建接口) | 重新生成读写权限 key |
|
||||||
|
| **404** | 资源不存在 | `product_id` / `category_id` 不存在 | 检查 ID 是否正确 |
|
||||||
|
| **409** | 资源冲突 | `offer_id` 重复 / 商品已存在 | 改用唯一 offer_id 或走更新接口 |
|
||||||
|
| **429** | 限流 | 请求频率超限 | 指数退避重试(1s → 2s → 4s) |
|
||||||
|
| **500** | 服务端错误 | Ozon 内部错误 | 重试 1-2 次,仍失败则联系支持 |
|
||||||
|
| **503** | 服务不可用 | 维护中 | 稍后重试 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 限流规则
|
||||||
|
|
||||||
|
官方未公开明确的限流阈值,根据社区经验:
|
||||||
|
|
||||||
|
- **常规接口**:~10 req/s
|
||||||
|
- **批量接口**(如 `/v3/product/list`):~5 req/s
|
||||||
|
- **同一 task_id 轮询**:建议间隔 ≥5s
|
||||||
|
|
||||||
|
触发 429 后:
|
||||||
|
1. 解析响应头 `Retry-After`(秒数)
|
||||||
|
2. 若无此头,使用指数退避:1s → 2s → 4s → 8s
|
||||||
|
3. 最多重试 3 次
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 超时建议
|
||||||
|
|
||||||
|
| 接口类型 | 超时时间 | 理由 |
|
||||||
|
|---|---|---|
|
||||||
|
| 查询类(类目/属性/商品列表) | 30s | 轻量请求 |
|
||||||
|
| 导入类(`/v3/product/import`) | 60-90s | 后端需校验 + 入库 |
|
||||||
|
| 轮询状态(`/v1/product/import/info`) | 30s | 单次轮询快,但需多次 |
|
||||||
|
| 图片上传 | 90s | 网络传输耗时 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 测试凭证有效性
|
||||||
|
|
||||||
|
### `/v1/roles` —— 获取当前 Key 的角色与权限
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST https://api-seller.ozon.ru/v1/roles
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求体**:空 `{}`
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"role_name": "Seller",
|
||||||
|
"permissions": [
|
||||||
|
"read:products",
|
||||||
|
"write:products",
|
||||||
|
"read:categories",
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**用途**:
|
||||||
|
- ✅ 验证凭证有效性(200 = 有效,401/403 = 无效)
|
||||||
|
- ✅ 查看权限范围(判断是否有 `write:products`)
|
||||||
|
- ✅ 零业务副作用(不消耗额度,不修改数据)
|
||||||
|
|
||||||
|
**V2 集成点**:`POST /api/shops/:id/test` 调此接口作连通性校验。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 请求 ID 追踪
|
||||||
|
|
||||||
|
部分接口响应包含 `request_id`(如图生图、导入任务),用于:
|
||||||
|
- 问题排查:联系 Ozon 支持时提供此 ID
|
||||||
|
- 幂等重试:某些接口可根据 `request_id` 避免重复创建
|
||||||
|
|
||||||
|
建议:每次请求在日志里记录 `request_id`(若有)与请求体摘要,便于回溯。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 环境
|
||||||
|
|
||||||
|
Ozon Seller API **仅生产环境**,无测试沙箱。调试时需注意:
|
||||||
|
- ⚠️ 所有操作都在真实店铺
|
||||||
|
- 💡 建议用「测试商品」标识(如 offer_id 前缀 `TEST-`)
|
||||||
|
- 🗑️ 测试后及时删除/归档测试商品
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. SDK 与工具
|
||||||
|
|
||||||
|
官方未提供 Python SDK,社区方案:
|
||||||
|
- 自封装 `httpx` 客户端(V2 采用,见 `server/services/ozon_client.py`)
|
||||||
|
- 第三方库:`ozon-api`(PyPI,非官方,更新滞后)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 相关链接
|
||||||
|
|
||||||
|
- [官方文档(中文)](https://docs.ozon.ru/api/seller/zh/)
|
||||||
|
- [官方文档(俄文)](https://docs.ozon.ru/api/seller/)
|
||||||
|
- [卖家后台](https://seller.ozon.ru/)
|
||||||
|
- [API 状态页](https://status.ozon.ru/)(维护公告)
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
# 类目树查询 API
|
||||||
|
|
||||||
|
> 官方文档:https://docs.ozon.ru/api/seller/zh/#operation/DescriptionCategoryAPI_GetTree
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 接口信息
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 方法 | POST |
|
||||||
|
| 路径 | `/v1/description-category/tree` |
|
||||||
|
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
|
||||||
|
| 用途 | 获取 Ozon 商品类目树(选择类目后才能发布商品) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 请求
|
||||||
|
|
||||||
|
### 请求体
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"language": "RU"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 参数说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| language | string | 可选 | 语言代码,可选值:`DEFAULT`(英文)、`RU`(俄文)、`EN`(英文)、`ZH_HANS`(简体中文)。默认 `DEFAULT` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 响应
|
||||||
|
|
||||||
|
### 成功响应(200)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"description_category_id": 17033876,
|
||||||
|
"category_name": "Термокружки",
|
||||||
|
"type_id": 97114,
|
||||||
|
"type_name": "Термокружка",
|
||||||
|
"disabled": false,
|
||||||
|
"children": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description_category_id": 17028922,
|
||||||
|
"category_name": "Посуда",
|
||||||
|
"type_id": 0,
|
||||||
|
"type_name": "",
|
||||||
|
"disabled": true,
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"description_category_id": 17033876,
|
||||||
|
"category_name": "Термокружки",
|
||||||
|
"type_id": 97114,
|
||||||
|
"type_name": "Термокружка",
|
||||||
|
"disabled": false,
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| description_category_id | integer | **类目 ID**(发布商品时必填) |
|
||||||
|
| category_name | string | 类目名称 |
|
||||||
|
| type_id | integer | **商品类型 ID**(发布商品时必填,与 category_id 配对) |
|
||||||
|
| type_name | string | 商品类型名称 |
|
||||||
|
| disabled | boolean | **是否禁用**。`true` = 不可建品(父类目),`false` = 可建品(末级类目) |
|
||||||
|
| children | array | 子类目(递归结构) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关键约束
|
||||||
|
|
||||||
|
1. **只有末级类目可建品**:`disabled=false` 的类目才能用于发布商品
|
||||||
|
2. **必须配对使用**:发布时需同时提供 `description_category_id` + `type_id`
|
||||||
|
3. **层级结构**:类目可能有多层嵌套(最多 5-6 层),需递归遍历找到末级
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 使用场景
|
||||||
|
|
||||||
|
### 场景 1:前端类目选择器
|
||||||
|
|
||||||
|
```
|
||||||
|
① 请求类目树(language=RU,给俄文用户看)
|
||||||
|
② 递归展开树形结构
|
||||||
|
③ 用户选择类目后,校验 disabled=false(若 true 则禁止选择或自动展开子级)
|
||||||
|
④ 选中后保存 description_category_id + type_id
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 2:服务端缓存
|
||||||
|
|
||||||
|
```
|
||||||
|
① 启动时拉取类目树(language=DEFAULT,英文字段名便于代码处理)
|
||||||
|
② 存入 category_tree 表(见 docs/v2/database.md §2.7)
|
||||||
|
③ TTL 24h,过期重拉
|
||||||
|
④ 用户选类目时直接查库,不频繁调 API
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 示例代码
|
||||||
|
|
||||||
|
### Python(服务端缓存)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import httpx
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
|
async def fetch_category_tree(
|
||||||
|
client_id: str,
|
||||||
|
api_key: str,
|
||||||
|
language: str = "DEFAULT"
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""拉取类目树并返回扁平化列表"""
|
||||||
|
headers = {
|
||||||
|
"Client-Id": client_id,
|
||||||
|
"Api-Key": api_key,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"https://api-seller.ozon.ru/v1/description-category/tree",
|
||||||
|
headers=headers,
|
||||||
|
json={"language": language}
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
# 递归扁平化
|
||||||
|
def flatten(nodes: List[Dict], level: int = 0, parent_id: int = 0):
|
||||||
|
flat = []
|
||||||
|
for node in nodes:
|
||||||
|
flat.append({
|
||||||
|
"description_category_id": node["description_category_id"],
|
||||||
|
"parent_id": parent_id,
|
||||||
|
"category_name": node["category_name"],
|
||||||
|
"type_id": node["type_id"],
|
||||||
|
"type_name": node["type_name"],
|
||||||
|
"disabled": node["disabled"],
|
||||||
|
"level": level,
|
||||||
|
"lang": language
|
||||||
|
})
|
||||||
|
if node.get("children"):
|
||||||
|
flat.extend(flatten(
|
||||||
|
node["children"],
|
||||||
|
level + 1,
|
||||||
|
node["description_category_id"]
|
||||||
|
))
|
||||||
|
return flat
|
||||||
|
|
||||||
|
return flatten(data.get("result", []))
|
||||||
|
```
|
||||||
|
|
||||||
|
### TypeScript(前端选择器)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CategoryNode {
|
||||||
|
description_category_id: number;
|
||||||
|
category_name: string;
|
||||||
|
type_id: number;
|
||||||
|
type_name: string;
|
||||||
|
disabled: boolean;
|
||||||
|
children: CategoryNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCategoryTree(language = 'RU'): Promise<CategoryNode[]> {
|
||||||
|
const resp = await fetch('/api/categories/tree?lang=' + language);
|
||||||
|
const data = await resp.json();
|
||||||
|
return data.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转为 antd Tree 数据结构
|
||||||
|
function toTreeData(nodes: CategoryNode[]): any[] {
|
||||||
|
return nodes.map(node => ({
|
||||||
|
key: `${node.description_category_id}-${node.type_id}`,
|
||||||
|
title: node.category_name,
|
||||||
|
disabled: node.disabled, // 父类目禁止选择
|
||||||
|
children: node.children.length > 0 ? toTreeData(node.children) : undefined,
|
||||||
|
// 保存原始数据,选中时取用
|
||||||
|
data: {
|
||||||
|
description_category_id: node.description_category_id,
|
||||||
|
type_id: node.type_id
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 缓存策略
|
||||||
|
|
||||||
|
### 全局缓存(推荐)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 类目树与店铺无关,所有店铺共用一份
|
||||||
|
# 启动时拉取,存内存 + 数据库
|
||||||
|
# TTL 24h(类目变化不频繁)
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
_category_tree_cache = None
|
||||||
|
_cache_time = None
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
async def get_category_tree_cached(language: str = "DEFAULT"):
|
||||||
|
global _category_tree_cache, _cache_time
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
if _category_tree_cache and _cache_time and (now - _cache_time) < timedelta(hours=24):
|
||||||
|
return _category_tree_cache
|
||||||
|
|
||||||
|
# 从任意店铺拉(类目树全局一致)
|
||||||
|
tree = await fetch_category_tree(any_client_id, any_api_key, language)
|
||||||
|
_category_tree_cache = tree
|
||||||
|
_cache_time = now
|
||||||
|
|
||||||
|
# 同时写数据库
|
||||||
|
await save_to_db(tree)
|
||||||
|
|
||||||
|
return tree
|
||||||
|
```
|
||||||
|
|
||||||
|
### 按需更新
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 用户反馈「找不到某类目」时手动刷新
|
||||||
|
async def refresh_category_tree():
|
||||||
|
global _category_tree_cache, _cache_time
|
||||||
|
_category_tree_cache = None
|
||||||
|
_cache_time = None
|
||||||
|
get_category_tree_cached.cache_clear()
|
||||||
|
return await get_category_tree_cached()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q1: 类目树很大吗?
|
||||||
|
|
||||||
|
**A**: 约 **1-2 万个类目节点**,JSON 约 3-5MB。首次拉取需几秒,后续从缓存读取。
|
||||||
|
|
||||||
|
### Q2: 多久更新一次?
|
||||||
|
|
||||||
|
**A**: Ozon 不定期新增类目(月级别),建议 **24h TTL + 手动刷新入口**。
|
||||||
|
|
||||||
|
### Q3: 不同语言的类目树结构一样吗?
|
||||||
|
|
||||||
|
**A**: 结构一致(`description_category_id` / `type_id` 相同),仅 `category_name` / `type_name` 翻译不同。建议:
|
||||||
|
- 服务端缓存 `DEFAULT`(英文,便于代码处理)
|
||||||
|
- 前端按用户语言拉取 `RU`(俄文,展示用)
|
||||||
|
|
||||||
|
### Q4: `type_id=0` 是什么意思?
|
||||||
|
|
||||||
|
**A**: 父类目(`disabled=true`)的 `type_id` 为 0,表示该节点不是商品类型,只是分类层级。只有末级类目的 `type_id > 0`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## V2 项目集成
|
||||||
|
|
||||||
|
### API 层(已实现)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# server/api/categories.py
|
||||||
|
|
||||||
|
@router.get("/categories/tree")
|
||||||
|
async def get_tree(
|
||||||
|
lang: str = Query("RU"),
|
||||||
|
shop_id: str = Query(...), # 需要店铺凭证
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
shop = await get_shop(db, shop_id)
|
||||||
|
tree = await ozon_client.get_category_tree(
|
||||||
|
shop.client_id_dec,
|
||||||
|
shop.api_key_dec,
|
||||||
|
lang
|
||||||
|
)
|
||||||
|
return {"result": tree}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端(待实现)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// studio/src/pages/product/components/CategoryPicker.tsx
|
||||||
|
|
||||||
|
import { Tree } from 'antd';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function CategoryPicker({ shopId, onChange }) {
|
||||||
|
const [treeData, setTreeData] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/categories/tree?shop_id=${shopId}&lang=RU`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => setTreeData(toTreeData(data.result)));
|
||||||
|
}, [shopId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tree
|
||||||
|
treeData={treeData}
|
||||||
|
onSelect={(keys, { node }) => {
|
||||||
|
if (!node.disabled) {
|
||||||
|
onChange(node.data); // { description_category_id, type_id }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [03-category-attributes.md](./03-category-attributes.md) —— 获取类目属性
|
||||||
|
- [docs/v2/database.md](../v2/database.md) §2.7 —— `category_tree` 表结构
|
||||||
|
- [docs/v2/ozon-publish.md](../v2/ozon-publish.md) §3 —— 类目字典缓存策略
|
||||||
@@ -0,0 +1,660 @@
|
|||||||
|
# 类目属性与字典值 API
|
||||||
|
|
||||||
|
> 官方文档:https://docs.ozon.ru/api/seller/zh/#operation/DescriptionCategoryAPI_GetAttributes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 获取类目属性
|
||||||
|
|
||||||
|
### 接口信息
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 方法 | POST |
|
||||||
|
| 路径 | `/v1/description-category/attribute` |
|
||||||
|
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
|
||||||
|
| 用途 | 获取指定类目的所有属性(发布商品时需填写) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"description_category_id": 17033876,
|
||||||
|
"type_id": 97114,
|
||||||
|
"language": "RU"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 参数说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| description_category_id | integer | ✅ 是 | 类目 ID(从类目树获取) |
|
||||||
|
| type_id | integer | ✅ 是 | 商品类型 ID(从类目树获取,与 category_id 配对) |
|
||||||
|
| language | string | 可选 | 语言代码:`DEFAULT` / `RU` / `EN` / `ZH_HANS`。默认 `DEFAULT` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"id": 85,
|
||||||
|
"name": "Бренд",
|
||||||
|
"description": "Укажите бренд товара",
|
||||||
|
"type": "String",
|
||||||
|
"is_collection": false,
|
||||||
|
"is_required": true,
|
||||||
|
"is_aspect": false,
|
||||||
|
"max_value_count": 1,
|
||||||
|
"dictionary_id": 28732,
|
||||||
|
"category_dependent": false,
|
||||||
|
"group_id": 0,
|
||||||
|
"group_name": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8229,
|
||||||
|
"name": "Цвет товара",
|
||||||
|
"description": "",
|
||||||
|
"type": "String",
|
||||||
|
"is_collection": false,
|
||||||
|
"is_required": false,
|
||||||
|
"is_aspect": true,
|
||||||
|
"max_value_count": 1,
|
||||||
|
"dictionary_id": 61405,
|
||||||
|
"category_dependent": false,
|
||||||
|
"group_id": 1,
|
||||||
|
"group_name": "Варианты"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9048,
|
||||||
|
"name": "Объем",
|
||||||
|
"description": "Укажите объем в миллилитрах",
|
||||||
|
"type": "Integer",
|
||||||
|
"is_collection": false,
|
||||||
|
"is_required": true,
|
||||||
|
"is_aspect": false,
|
||||||
|
"max_value_count": 1,
|
||||||
|
"dictionary_id": 0,
|
||||||
|
"category_dependent": false,
|
||||||
|
"group_id": 2,
|
||||||
|
"group_name": "Основные"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| **id** | integer | **属性 ID**(发布时填 `attributes[].id`) |
|
||||||
|
| **name** | string | 属性名称(如"品牌"、"颜色") |
|
||||||
|
| description | string | 属性说明(填写提示) |
|
||||||
|
| **type** | string | 值类型:`String` / `Integer` / `Decimal` / `Boolean` / `URL` |
|
||||||
|
| **is_required** | boolean | **是否必填**。`true` = 必须填写,否则发布失败 |
|
||||||
|
| **is_aspect** | boolean | **是否变体属性**(如颜色/尺码)。`true` = 该属性用于区分 SKU 变体 |
|
||||||
|
| **is_collection** | boolean | 是否多值。`true` = 可填多个值(如"适用场景:家用,办公") |
|
||||||
|
| max_value_count | integer | 最多值数量(`is_collection=true` 时有效) |
|
||||||
|
| **dictionary_id** | integer | **字典 ID**。`> 0` = 有预设值字典(需调字典值接口),`0` = 自由输入 |
|
||||||
|
| category_dependent | boolean | 字典值是否依赖类目(`true` = 不同类目的字典值不同) |
|
||||||
|
| group_id | integer | 属性分组 ID |
|
||||||
|
| group_name | string | 属性分组名(如"基本信息"、"变体") |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 关键字段组合
|
||||||
|
|
||||||
|
| 组合 | 含义 | 示例 | 填写方式 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `is_required=true` | **必填** | 品牌、尺寸、重量 | 必须有值,否则发布失败 |
|
||||||
|
| `dictionary_id > 0` | **有字典** | 品牌、颜色、材质 | 值必须从字典选(dictionary_value_id) |
|
||||||
|
| `dictionary_id = 0` | **自由输入** | 商品名、描述、数值 | 直接填文本/数字 |
|
||||||
|
| `is_aspect=true` | **变体属性** | 颜色、尺码 | 用于区分 SKU(不同颜色 = 不同 SKU) |
|
||||||
|
| `is_collection=true` | **多值** | 适用场景、材质组成 | 可传数组 `["值1", "值2"]` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 获取属性值字典
|
||||||
|
|
||||||
|
### 接口信息
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 方法 | POST |
|
||||||
|
| 路径 | `/v1/description-category/attribute/values` |
|
||||||
|
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
|
||||||
|
| 用途 | 获取属性的预设值字典(`dictionary_id > 0` 的属性) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"attribute_id": 85,
|
||||||
|
"description_category_id": 17033876,
|
||||||
|
"type_id": 97114,
|
||||||
|
"language": "RU",
|
||||||
|
"limit": 1000,
|
||||||
|
"last_value_id": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 参数说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| attribute_id | integer | ✅ 是 | 属性 ID |
|
||||||
|
| description_category_id | integer | ✅ 是 | 类目 ID |
|
||||||
|
| type_id | integer | ✅ 是 | 商品类型 ID |
|
||||||
|
| language | string | 可选 | 语言代码 |
|
||||||
|
| limit | integer | 可选 | 每页数量,最大 **5000**,默认 1000 |
|
||||||
|
| last_value_id | integer | 可选 | 分页游标(上一页最后一个值的 `id`),首页传 0 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"id": 971082156,
|
||||||
|
"value": "Thermos",
|
||||||
|
"info": "",
|
||||||
|
"picture": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 971317107,
|
||||||
|
"value": "Stanley",
|
||||||
|
"info": "",
|
||||||
|
"picture": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"has_next": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| **id** | integer | **字典值 ID**(发布时填 `attributes[].values[].dictionary_value_id`) |
|
||||||
|
| **value** | string | 字典值文本(如品牌名"Thermos") |
|
||||||
|
| info | string | 补充说明 |
|
||||||
|
| picture | string | 值配图 URL(部分属性有,如颜色) |
|
||||||
|
| **has_next** | boolean | 是否有下一页(`true` = 用最后一个 `id` 继续分页) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 分页示例
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def fetch_all_values(attribute_id, category_id, type_id):
|
||||||
|
all_values = []
|
||||||
|
last_id = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
resp = await client.post(
|
||||||
|
"https://api-seller.ozon.ru/v1/description-category/attribute/values",
|
||||||
|
json={
|
||||||
|
"attribute_id": attribute_id,
|
||||||
|
"description_category_id": category_id,
|
||||||
|
"type_id": type_id,
|
||||||
|
"language": "RU",
|
||||||
|
"limit": 5000,
|
||||||
|
"last_value_id": last_id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
data = resp.json()
|
||||||
|
values = data.get("result", [])
|
||||||
|
all_values.extend(values)
|
||||||
|
|
||||||
|
if not data.get("has_next") or not values:
|
||||||
|
break
|
||||||
|
|
||||||
|
last_id = values[-1]["id"]
|
||||||
|
|
||||||
|
return all_values
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 按关键词搜索属性值
|
||||||
|
|
||||||
|
### 接口信息
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 方法 | POST |
|
||||||
|
| 路径 | `/v1/description-category/attribute/values/search` |
|
||||||
|
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
|
||||||
|
| 用途 | 模糊搜索字典值(避免拉全量字典) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"attribute_id": 85,
|
||||||
|
"description_category_id": 17033876,
|
||||||
|
"type_id": 97114,
|
||||||
|
"language": "RU",
|
||||||
|
"value": "Ther",
|
||||||
|
"limit": 100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 参数说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| attribute_id | integer | ✅ 是 | 属性 ID |
|
||||||
|
| description_category_id | integer | ✅ 是 | 类目 ID |
|
||||||
|
| type_id | integer | ✅ 是 | 商品类型 ID |
|
||||||
|
| language | string | 可选 | 语言代码 |
|
||||||
|
| **value** | string | ✅ 是 | 搜索关键词(≥2 个字符) |
|
||||||
|
| limit | integer | 可选 | 返回数量,最大 **100**,默认 50 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"id": 971082156,
|
||||||
|
"value": "Thermos",
|
||||||
|
"info": "",
|
||||||
|
"picture": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 971982345,
|
||||||
|
"value": "Thermocafe",
|
||||||
|
"info": "",
|
||||||
|
"picture": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 属性映射工作流
|
||||||
|
|
||||||
|
### 场景:采集来的参数 → Ozon 属性
|
||||||
|
|
||||||
|
```
|
||||||
|
采集原文(raw.params):
|
||||||
|
[
|
||||||
|
{ "key": "Материал", "value": "Нержавеющая сталь" },
|
||||||
|
{ "key": "Объем", "value": "500 мл" },
|
||||||
|
{ "key": "Бренд", "value": "Thermos" }
|
||||||
|
]
|
||||||
|
|
||||||
|
↓ ① 自动匹配属性名
|
||||||
|
|
||||||
|
attributes = [
|
||||||
|
{ id: 8505, name: "Материал" }, // 材质
|
||||||
|
{ id: 9048, name: "Объем" }, // 容量
|
||||||
|
{ id: 85, name: "Бренд" } // 品牌
|
||||||
|
]
|
||||||
|
|
||||||
|
↓ ② 对有字典的属性(dictionary_id > 0),搜索字典值
|
||||||
|
|
||||||
|
POST /attribute/values/search {
|
||||||
|
attribute_id: 85, // 品牌
|
||||||
|
value: "Thermos"
|
||||||
|
}
|
||||||
|
→ { id: 971082156, value: "Thermos" }
|
||||||
|
|
||||||
|
↓ ③ 组装最终 attributes
|
||||||
|
|
||||||
|
products.attributes = [
|
||||||
|
{
|
||||||
|
"complex_id": 0,
|
||||||
|
"id": 8505,
|
||||||
|
"values": [{ "value": "Нержавеющая сталь" }] // 材质无字典,直接填
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"complex_id": 0,
|
||||||
|
"id": 9048,
|
||||||
|
"values": [{ "value": "500" }] // 容量是数值,提取数字
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"complex_id": 0,
|
||||||
|
"id": 85,
|
||||||
|
"values": [{ "dictionary_value_id": 971082156, "value": "Thermos" }] // 品牌有字典
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 自动匹配策略
|
||||||
|
|
||||||
|
### 策略 A:归一化 + 模糊匹配
|
||||||
|
|
||||||
|
```python
|
||||||
|
import re
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
|
||||||
|
def normalize(text: str) -> str:
|
||||||
|
"""归一化:小写 + 去标点 + 词干"""
|
||||||
|
text = text.lower().strip()
|
||||||
|
text = re.sub(r'[^\w\s]', '', text)
|
||||||
|
# 俄文词干化(需 pymorphy2 库)
|
||||||
|
# text = morph.parse(text)[0].normal_form
|
||||||
|
return text
|
||||||
|
|
||||||
|
def fuzzy_match(采集key: str, 属性列表: list, threshold=0.8):
|
||||||
|
"""模糊匹配:相似度 > 0.8 即认为匹配"""
|
||||||
|
norm_key = normalize(采集key)
|
||||||
|
best = None
|
||||||
|
best_score = 0
|
||||||
|
|
||||||
|
for attr in 属性列表:
|
||||||
|
norm_name = normalize(attr["name"])
|
||||||
|
score = SequenceMatcher(None, norm_key, norm_name).ratio()
|
||||||
|
if score > best_score:
|
||||||
|
best = attr
|
||||||
|
best_score = score
|
||||||
|
|
||||||
|
return best if best_score >= threshold else None
|
||||||
|
```
|
||||||
|
|
||||||
|
### 策略 B:关键词映射表
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 预定义常见映射(中文采集 key → Ozon 属性 name)
|
||||||
|
KEYWORD_MAP = {
|
||||||
|
"品牌": ["Бренд", "Brand"],
|
||||||
|
"材质": ["Материал", "Material"],
|
||||||
|
"重量": ["Вес", "Weight"],
|
||||||
|
"尺寸": ["Размер", "Size"],
|
||||||
|
"颜色": ["Цвет", "Color"],
|
||||||
|
# ... 补充更多
|
||||||
|
}
|
||||||
|
|
||||||
|
def keyword_match(采集key: str, 属性列表: list):
|
||||||
|
for cn_key, ru_keys in KEYWORD_MAP.items():
|
||||||
|
if cn_key in 采集key:
|
||||||
|
for attr in 属性列表:
|
||||||
|
if any(rk in attr["name"] for rk in ru_keys):
|
||||||
|
return attr
|
||||||
|
return None
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 必填项校验
|
||||||
|
|
||||||
|
### 发布前校验
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def validate_required_attributes(
|
||||||
|
category_id: int,
|
||||||
|
type_id: int,
|
||||||
|
attributes: list
|
||||||
|
) -> list[str]:
|
||||||
|
"""返回缺失的必填属性名列表"""
|
||||||
|
# 获取该类目的所有属性
|
||||||
|
attrs = await fetch_category_attributes(category_id, type_id)
|
||||||
|
|
||||||
|
# 提取必填属性
|
||||||
|
required = [a for a in attrs if a["is_required"]]
|
||||||
|
|
||||||
|
# 已填写的属性 ID
|
||||||
|
filled_ids = {a["id"] for a in attributes}
|
||||||
|
|
||||||
|
# 找出缺失的
|
||||||
|
missing = [a["name"] for a in required if a["id"] not in filled_ids]
|
||||||
|
|
||||||
|
return missing
|
||||||
|
|
||||||
|
# 使用
|
||||||
|
missing = await validate_required_attributes(17033876, 97114, product.attributes)
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"缺少必填属性:{', '.join(missing)}")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 缓存策略
|
||||||
|
|
||||||
|
### 属性列表缓存
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 按 (category_id, type_id) 缓存
|
||||||
|
# TTL 7 天(属性变化极少)
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
@lru_cache(maxsize=500)
|
||||||
|
async def get_attributes_cached(category_id: int, type_id: int):
|
||||||
|
# 先查数据库
|
||||||
|
cached = await db.query(CategoryAttribute).filter_by(
|
||||||
|
description_category_id=category_id,
|
||||||
|
type_id=type_id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
# 未缓存,调 API
|
||||||
|
attrs = await fetch_category_attributes(category_id, type_id)
|
||||||
|
|
||||||
|
# 写入数据库
|
||||||
|
await save_attributes_to_db(attrs)
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字典值缓存(按需)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 字典值可能很大(数万条),不全量缓存
|
||||||
|
# 策略:用户映射到某属性时,才拉该属性的字典(且优先用 /search)
|
||||||
|
|
||||||
|
async def get_attribute_values(attr_id, category_id, type_id, keyword=None):
|
||||||
|
if keyword:
|
||||||
|
# 有关键词 → 搜索接口(limit 100)
|
||||||
|
return await search_values(attr_id, category_id, type_id, keyword)
|
||||||
|
else:
|
||||||
|
# 无关键词 → 拉全量(分页,存数据库)
|
||||||
|
return await fetch_all_values(attr_id, category_id, type_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 前端交互设计
|
||||||
|
|
||||||
|
### 属性映射 UI(推荐)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ 类目属性映射 │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ 采集属性 → Ozon 属性 │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ ✅ Материал (材质) → [自动] Материал (8505) │
|
||||||
|
│ 值:Нержавеющая сталь │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ ✅ Бренд (品牌) → [自动] Бренд (85) ⚠️必填 │
|
||||||
|
│ 值:Thermos → 字典值:[选择 ▼] │
|
||||||
|
│ ├ Thermos ✅ │
|
||||||
|
│ ├ Stanley │
|
||||||
|
│ └ ... │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ ⚠️ 未匹配:包装重量 │
|
||||||
|
│ → [手动选择属性 ▼] │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ ❌ 缺少必填属性: │
|
||||||
|
│ - Объем (容量) [+添加] │
|
||||||
|
│ - Цвет (颜色) [+添加] │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 常见问题
|
||||||
|
|
||||||
|
### Q1: 属性太多怎么办?
|
||||||
|
|
||||||
|
**A**: 一个类目可能有 **50-100+ 属性**,但常用的只有 10-20 个。策略:
|
||||||
|
- 必填属性置顶 + 高亮
|
||||||
|
- 已匹配属性展开,未匹配折叠
|
||||||
|
- 提供搜索/筛选
|
||||||
|
|
||||||
|
### Q2: 字典值有多大?
|
||||||
|
|
||||||
|
**A**:
|
||||||
|
- 小字典(颜色/材质):几十到几百条
|
||||||
|
- 大字典(品牌):**数万条**(如品牌字典 > 50,000)
|
||||||
|
- 策略:**优先用 `/values/search`**,避免拉全量
|
||||||
|
|
||||||
|
### Q3: 自由输入的属性怎么填?
|
||||||
|
|
||||||
|
**A**: `dictionary_id=0` 的属性直接填 `value`,无需 `dictionary_value_id`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 9048,
|
||||||
|
"values": [{ "value": "500" }] // 容量,数值类型
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q4: 如何处理多值属性?
|
||||||
|
|
||||||
|
**A**: `is_collection=true` 时传数组:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 10096,
|
||||||
|
"values": [
|
||||||
|
{ "value": "家用" },
|
||||||
|
{ "value": "办公" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. V2 项目集成
|
||||||
|
|
||||||
|
### API 层(已实现)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# server/api/categories.py
|
||||||
|
|
||||||
|
@router.get("/categories/{category_id}/attributes")
|
||||||
|
async def get_attributes(
|
||||||
|
category_id: int,
|
||||||
|
type_id: int = Query(...),
|
||||||
|
shop_id: str = Query(...),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
shop = await get_shop(db, shop_id)
|
||||||
|
attrs = await ozon_client.get_category_attributes(
|
||||||
|
shop.client_id_dec,
|
||||||
|
shop.api_key_dec,
|
||||||
|
category_id,
|
||||||
|
type_id
|
||||||
|
)
|
||||||
|
return {"result": attrs}
|
||||||
|
|
||||||
|
@router.get("/categories/attribute/{attribute_id}/values")
|
||||||
|
async def get_attribute_values(
|
||||||
|
attribute_id: int,
|
||||||
|
category_id: int = Query(...),
|
||||||
|
type_id: int = Query(...),
|
||||||
|
q: str = Query(None), # 搜索关键词
|
||||||
|
shop_id: str = Query(...),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
shop = await get_shop(db, shop_id)
|
||||||
|
|
||||||
|
if q and len(q) >= 2:
|
||||||
|
# 搜索接口
|
||||||
|
values = await ozon_client.search_attribute_values(
|
||||||
|
shop.client_id_dec, shop.api_key_dec,
|
||||||
|
attribute_id, category_id, type_id, q
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 全量拉取(分页)
|
||||||
|
values = await ozon_client.get_attribute_values(
|
||||||
|
shop.client_id_dec, shop.api_key_dec,
|
||||||
|
attribute_id, category_id, type_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"result": values}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端(待实现)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// studio/src/pages/product/components/AttributeMapper.tsx
|
||||||
|
|
||||||
|
import { Form, Select, Input, Tag } from 'antd';
|
||||||
|
|
||||||
|
export function AttributeMapper({ categoryId, typeId, rawParams, onChange }) {
|
||||||
|
const [attributes, setAttributes] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/categories/${categoryId}/attributes?type_id=${typeId}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => setAttributes(data.result));
|
||||||
|
}, [categoryId, typeId]);
|
||||||
|
|
||||||
|
// 自动匹配
|
||||||
|
const autoMatch = () => {
|
||||||
|
const matched = rawParams.map(p => {
|
||||||
|
const attr = attributes.find(a =>
|
||||||
|
normalize(a.name) === normalize(p.key)
|
||||||
|
);
|
||||||
|
return attr ? { ...p, attrId: attr.id, attr } : p;
|
||||||
|
});
|
||||||
|
return matched;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{autoMatch().map((item, i) => (
|
||||||
|
<Form.Item
|
||||||
|
key={i}
|
||||||
|
label={item.key}
|
||||||
|
required={item.attr?.is_required}
|
||||||
|
>
|
||||||
|
{item.attr?.dictionary_id > 0 ? (
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
placeholder="选择字典值"
|
||||||
|
onSearch={(q) => fetchValues(item.attr.id, q)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input defaultValue={item.value} />
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [02-category-tree.md](./02-category-tree.md) —— 获取类目树
|
||||||
|
- [04-product-import.md](./04-product-import.md) —— 发布商品(使用属性)
|
||||||
|
- [docs/v2/database.md](../v2/database.md) §2.7 —— 属性缓存表结构
|
||||||
|
- [docs/v2/ozon-publish.md](../v2/ozon-publish.md) §3 —— 属性映射策略
|
||||||
@@ -0,0 +1,616 @@
|
|||||||
|
# 商品导入(创建/更新)API
|
||||||
|
|
||||||
|
> 官方文档:https://docs.ozon.ru/api/seller/zh/#operation/ProductAPI_ImportProductsV3
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 接口信息
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 方法 | POST |
|
||||||
|
| 路径 | `/v3/product/import` |
|
||||||
|
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
|
||||||
|
| 用途 | **创建或更新商品**(Ozon 核心接口) |
|
||||||
|
| 异步 | ✅ 返回 `task_id`,需轮询 `/v1/product/import/info` 获取最终状态 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 请求体结构
|
||||||
|
|
||||||
|
### 完整示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"offer_id": "MY-THERMOS-001",
|
||||||
|
"name": "Термокружка Thermos из нержавеющей стали 500 мл",
|
||||||
|
"description": "Термокружка из высококачественной нержавеющей стали. Сохраняет температуру до 6 часов.",
|
||||||
|
"description_category_id": 17033876,
|
||||||
|
"type_id": 97114,
|
||||||
|
"price": "2990",
|
||||||
|
"old_price": "3490",
|
||||||
|
"currency_code": "RUB",
|
||||||
|
"vat": "0",
|
||||||
|
"depth": 80,
|
||||||
|
"width": 80,
|
||||||
|
"height": 200,
|
||||||
|
"dimension_unit": "mm",
|
||||||
|
"weight": 320,
|
||||||
|
"weight_unit": "g",
|
||||||
|
"images": [
|
||||||
|
"https://cdn.example.com/thermos-main-1.jpg",
|
||||||
|
"https://cdn.example.com/thermos-main-2.jpg"
|
||||||
|
],
|
||||||
|
"primary_image": "",
|
||||||
|
"images360": [],
|
||||||
|
"color_image": "",
|
||||||
|
"barcode": "",
|
||||||
|
"attributes": [
|
||||||
|
{
|
||||||
|
"complex_id": 0,
|
||||||
|
"id": 85,
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"dictionary_value_id": 971082156,
|
||||||
|
"value": "Thermos"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"complex_id": 0,
|
||||||
|
"id": 8505,
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"value": "Нержавеющая сталь"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"complex_attributes": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 字段说明
|
||||||
|
|
||||||
|
### 基本信息
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **offer_id** | string | ✅ 是 | **自己的货号**(唯一标识,用于更新)。最长 255 字符 |
|
||||||
|
| **name** | string | ✅ 是 | 商品名称。最长 500 字符 |
|
||||||
|
| **description** | string | ✅ 是 | 商品描述。最长 5000 字符,支持 HTML 标签 |
|
||||||
|
| **description_category_id** | integer | ✅ 是 | 类目 ID(从类目树获取) |
|
||||||
|
| **type_id** | integer | ✅ 是 | 商品类型 ID(从类目树获取) |
|
||||||
|
|
||||||
|
### 价格
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **price** | string | ✅ 是 | 销售价(字符串格式,如 `"2990"` = 2990 卢布) |
|
||||||
|
| old_price | string | 可选 | 划线价(原价),用于展示折扣 |
|
||||||
|
| **currency_code** | string | ✅ 是 | 币种,通常 `"RUB"`(卢布)。也可 `"CNY"` 等 |
|
||||||
|
| **vat** | string | ✅ 是 | 增值税率:`"0"` / `"0.1"` / `"0.2"`。俄罗斯默认 `"0"` |
|
||||||
|
|
||||||
|
### 尺寸与重量
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **depth** | number | ✅ 是 | 长度(包装尺寸)。**不能为 0** |
|
||||||
|
| **width** | number | ✅ 是 | 宽度(包装尺寸)。**不能为 0** |
|
||||||
|
| **height** | number | ✅ 是 | 高度(包装尺寸)。**不能为 0** |
|
||||||
|
| **dimension_unit** | string | ✅ 是 | 尺寸单位:`"mm"` / `"cm"` |
|
||||||
|
| **weight** | number | ✅ 是 | 重量(包装重量)。**不能为 0** |
|
||||||
|
| **weight_unit** | string | ✅ 是 | 重量单位:`"g"` / `"kg"` |
|
||||||
|
|
||||||
|
⚠️ **硬约束**:尺寸和重量必须 **> 0**,否则 API 返回 400 错误。
|
||||||
|
|
||||||
|
### 图片
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **images** | array | ✅ 是 | **主图数组**(最多 15 张)。顺序即展示顺序,第一张为主图 |
|
||||||
|
| primary_image | string | 可选 | 主图(单独指定)。若使用则 `images` 最多 14 张 |
|
||||||
|
| images360 | array | 可选 | 360° 图片数组 |
|
||||||
|
| color_image | string | 可选 | 营销色图(部分类目支持) |
|
||||||
|
|
||||||
|
⚠️ **硬约束**:
|
||||||
|
- 图片 URL 必须是 **https 公网直链**(http 会被拒绝)
|
||||||
|
- 图片需可访问(Ozon 服务器会主动拉取)
|
||||||
|
- 建议尺寸:≥ 700×700 px,白底,主体占画面 80%+
|
||||||
|
|
||||||
|
### 属性
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **attributes** | array | ✅ 是 | 商品属性数组(从类目属性获取) |
|
||||||
|
| attributes[].complex_id | integer | ✅ 是 | 复杂属性 ID,通常填 `0` |
|
||||||
|
| attributes[].id | integer | ✅ 是 | 属性 ID |
|
||||||
|
| attributes[].values | array | ✅ 是 | 属性值数组 |
|
||||||
|
| values[].dictionary_value_id | integer | 条件 | 字典值 ID(属性有字典时必填) |
|
||||||
|
| values[].value | string | ✅ 是 | 属性值文本 |
|
||||||
|
|
||||||
|
### 复杂属性
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| complex_attributes | array | 可选 | 复杂属性(视频、尺码表等) |
|
||||||
|
|
||||||
|
### 其他
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| barcode | string | 可选 | 条形码 |
|
||||||
|
| pdf_list | array | 可选 | PDF 文件 URL 列表 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 响应
|
||||||
|
|
||||||
|
### 成功响应(200)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"task_id": 123456789
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| task_id | integer | **任务 ID**(用于轮询状态,见下节) |
|
||||||
|
|
||||||
|
⚠️ **此时商品尚未创建**,需轮询 `/v1/product/import/info` 获取最终结果。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 轮询任务状态
|
||||||
|
|
||||||
|
### 接口信息
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 方法 | POST |
|
||||||
|
| 路径 | `/v1/product/import/info` |
|
||||||
|
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"task_id": 123456789
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"offer_id": "MY-THERMOS-001",
|
||||||
|
"product_id": 987654321,
|
||||||
|
"status": "imported",
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 状态值
|
||||||
|
|
||||||
|
| status | 含义 | 处理 |
|
||||||
|
|---|---|---|
|
||||||
|
| **imported** | ✅ 导入成功 | 保存 `product_id`,标记商品为 `published` |
|
||||||
|
| **pending** | ⏳ 排队中 | 继续轮询(间隔 5s) |
|
||||||
|
| **processing** | ⏳ 处理中 | 继续轮询(间隔 5s) |
|
||||||
|
| **moderation** | ⏳ 审核中 | 继续轮询(间隔 30s,审核可能需几小时) |
|
||||||
|
| **failed** | ❌ 失败 | 读取 `errors` 数组,展示错误给用户 |
|
||||||
|
|
||||||
|
### 错误结构
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"offer_id": "MY-THERMOS-001",
|
||||||
|
"product_id": 0,
|
||||||
|
"status": "failed",
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"code": "INVALID_ATTRIBUTE",
|
||||||
|
"message": "Attribute 'Бренд' is required",
|
||||||
|
"field": "attributes"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 轮询策略
|
||||||
|
|
||||||
|
### 推荐策略
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def wait_for_import(task_id: int, timeout=300):
|
||||||
|
"""轮询导入状态,最多等待 5 分钟"""
|
||||||
|
start = time.time()
|
||||||
|
interval = 5 # 初始间隔 5s
|
||||||
|
|
||||||
|
while time.time() - start < timeout:
|
||||||
|
resp = await ozon_client.get_import_info(task_id)
|
||||||
|
item = resp["result"]["items"][0]
|
||||||
|
status = item["status"]
|
||||||
|
|
||||||
|
if status == "imported":
|
||||||
|
return {"success": True, "product_id": item["product_id"]}
|
||||||
|
|
||||||
|
if status == "failed":
|
||||||
|
return {"success": False, "errors": item["errors"]}
|
||||||
|
|
||||||
|
if status == "moderation":
|
||||||
|
interval = 30 # 审核阶段降低频率
|
||||||
|
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
|
||||||
|
# 超时:不算失败,标记为"审核中"继续后台轮询
|
||||||
|
return {"success": None, "status": "timeout"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 后台轮询(推荐)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 用户提交发布后立即返回,后台协程轮询
|
||||||
|
# 状态变化时通知前端(WebSocket / 长轮询 / 前端定时刷新)
|
||||||
|
|
||||||
|
async def background_poll_task(task_id: int, product_id: str):
|
||||||
|
"""后台协程,轮询直到完成或失败"""
|
||||||
|
result = await wait_for_import(task_id, timeout=3600) # 最多 1 小时
|
||||||
|
|
||||||
|
# 更新数据库
|
||||||
|
await db.execute(
|
||||||
|
update(Product)
|
||||||
|
.where(Product.id == product_id)
|
||||||
|
.values(
|
||||||
|
stage="published" if result["success"] else "failed",
|
||||||
|
ozon_product_id=result.get("product_id"),
|
||||||
|
published_at=datetime.utcnow() if result["success"] else None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 记录任务结果
|
||||||
|
await db.execute(
|
||||||
|
update(PublishTask)
|
||||||
|
.where(PublishTask.ozon_task_id == task_id)
|
||||||
|
.values(
|
||||||
|
status=result.get("status"),
|
||||||
|
errors=result.get("errors"),
|
||||||
|
completed_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 创建 vs 更新
|
||||||
|
|
||||||
|
### 创建新商品
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"offer_id": "NEW-PRODUCT-001", // 全新 offer_id
|
||||||
|
// ... 其他字段
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 如果 `offer_id` 不存在 → 创建新商品
|
||||||
|
- 如果 `offer_id` 已存在 → 返回 409 冲突
|
||||||
|
|
||||||
|
### 更新已有商品
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"offer_id": "EXISTING-001", // 已存在的 offer_id
|
||||||
|
// ... 要更新的字段(可部分更新)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
或使用 `product_id`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"product_id": 987654321, // Ozon 商品 ID
|
||||||
|
// ... 要更新的字段
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **注意**:
|
||||||
|
- 更新时,未传的字段**保持原值**(非清空)
|
||||||
|
- 图片数组传空 `[]` 会清空图片(需小心)
|
||||||
|
- 建议更新前先读取当前值(`/v3/product/info/list`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 批量导入
|
||||||
|
|
||||||
|
单次请求最多 **100 个 item**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{ "offer_id": "PROD-001", /* ... */ },
|
||||||
|
{ "offer_id": "PROD-002", /* ... */ },
|
||||||
|
// ... 最多 100 个
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应包含每个 item 的状态:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"items": [
|
||||||
|
{ "offer_id": "PROD-001", "status": "imported", "product_id": 111 },
|
||||||
|
{ "offer_id": "PROD-002", "status": "failed", "errors": [...] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 常见错误
|
||||||
|
|
||||||
|
### 错误码速查
|
||||||
|
|
||||||
|
| code | message | 原因 | 解决 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `INVALID_PARAMETER` | 参数错误 | 必填字段缺失 / 格式错误 | 检查字段完整性 |
|
||||||
|
| `INVALID_ATTRIBUTE` | 属性错误 | 缺少必填属性 / 字典值不匹配 | 补全必填属性,校验字典值 |
|
||||||
|
| `INVALID_CATEGORY` | 类目错误 | `description_category_id` 不存在或已禁用 | 重新选择类目 |
|
||||||
|
| `INVALID_IMAGE` | 图片错误 | URL 不可访问 / 非 https / 格式不支持 | 检查图片 URL 有效性 |
|
||||||
|
| `OFFER_ID_DUPLICATE` | offer_id 重复 | 该 offer_id 已存在 | 换一个唯一 offer_id 或走更新 |
|
||||||
|
| `DIMENSION_REQUIRED` | 尺寸必填 | 尺寸/重量为 0 或缺失 | 填写正确尺寸重量 |
|
||||||
|
| `PRICE_INVALID` | 价格错误 | 价格 ≤ 0 或格式错误 | 检查价格字段 |
|
||||||
|
|
||||||
|
### 典型错误示例
|
||||||
|
|
||||||
|
#### 错误 1:尺寸为 0
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"code": "DIMENSION_REQUIRED",
|
||||||
|
"message": "Dimensions must be greater than 0",
|
||||||
|
"field": "weight"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**解决**:确保 `depth/width/height/weight` 都 > 0。
|
||||||
|
|
||||||
|
#### 错误 2:缺少必填属性
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"code": "INVALID_ATTRIBUTE",
|
||||||
|
"message": "Required attribute 'Бренд' (id=85) is missing",
|
||||||
|
"field": "attributes"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**解决**:补充缺失的必填属性。
|
||||||
|
|
||||||
|
#### 错误 3:图片 URL 不可访问
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"code": "INVALID_IMAGE",
|
||||||
|
"message": "Image URL is not accessible: https://...",
|
||||||
|
"field": "images[0]"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**解决**:
|
||||||
|
1. 检查 URL 是 https(非 http)
|
||||||
|
2. 检查 URL 公网可访问(Ozon 服务器需能拉取)
|
||||||
|
3. 检查图片格式(支持 jpg/png/webp)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 发布后操作
|
||||||
|
|
||||||
|
### 设置库存(必须)
|
||||||
|
|
||||||
|
商品导入成功后**不会自动上架**,需设置库存才能开售:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /v2/products/stocks
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"stocks": [
|
||||||
|
{
|
||||||
|
"product_id": 987654321,
|
||||||
|
"offer_id": "MY-THERMOS-001",
|
||||||
|
"stock": 100,
|
||||||
|
"warehouse_id": 12345678
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ 不设置库存 → 商品在后台但不可购买。
|
||||||
|
|
||||||
|
### 查询商品详情
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /v3/product/info/list
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"offer_id": ["MY-THERMOS-001"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
返回商品完整信息(含审核状态、图片、属性)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. V2 项目集成
|
||||||
|
|
||||||
|
### API 层(已实现)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# server/api/publish.py
|
||||||
|
|
||||||
|
@router.post("/products/{product_id}/publish")
|
||||||
|
async def publish_product(
|
||||||
|
product_id: str,
|
||||||
|
shop_id: str = Body(...),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
# 1. 取商品数据
|
||||||
|
product = await get_product(db, product_id)
|
||||||
|
|
||||||
|
# 2. 校验必填项
|
||||||
|
validate_required_fields(product)
|
||||||
|
|
||||||
|
# 3. 组装 ImportProductsV3 请求体
|
||||||
|
item = build_import_item(product)
|
||||||
|
|
||||||
|
# 4. 调用 Ozon API
|
||||||
|
shop = await get_shop(db, shop_id)
|
||||||
|
resp = await ozon_client.import_products(
|
||||||
|
shop.client_id_dec,
|
||||||
|
shop.api_key_dec,
|
||||||
|
[item]
|
||||||
|
)
|
||||||
|
|
||||||
|
task_id = resp["result"]["task_id"]
|
||||||
|
|
||||||
|
# 5. 记录发布任务
|
||||||
|
task = PublishTask(
|
||||||
|
product_id=product_id,
|
||||||
|
shop_id=shop_id,
|
||||||
|
ozon_task_id=task_id,
|
||||||
|
status="pending",
|
||||||
|
request_payload=item
|
||||||
|
)
|
||||||
|
db.add(task)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# 6. 启动后台轮询
|
||||||
|
asyncio.create_task(background_poll_task(task_id, product_id))
|
||||||
|
|
||||||
|
return {"task_id": task_id}
|
||||||
|
|
||||||
|
def build_import_item(product: Product) -> dict:
|
||||||
|
"""组装 ImportProductsV3 items[0]"""
|
||||||
|
return {
|
||||||
|
"offer_id": product.offer_id,
|
||||||
|
"name": product.name,
|
||||||
|
"description": product.description,
|
||||||
|
"description_category_id": product.description_category_id,
|
||||||
|
"type_id": product.type_id,
|
||||||
|
"price": str(product.price),
|
||||||
|
"old_price": str(product.old_price) if product.old_price else "",
|
||||||
|
"currency_code": product.currency_code,
|
||||||
|
"vat": product.vat,
|
||||||
|
"depth": product.depth,
|
||||||
|
"width": product.width,
|
||||||
|
"height": product.height,
|
||||||
|
"dimension_unit": product.dimension_unit,
|
||||||
|
"weight": product.weight,
|
||||||
|
"weight_unit": product.weight_unit,
|
||||||
|
"images": product.images, # 七牛 URL 数组
|
||||||
|
"primary_image": product.primary_image or "",
|
||||||
|
"images360": product.images360 or [],
|
||||||
|
"color_image": product.color_image or "",
|
||||||
|
"barcode": product.barcode or "",
|
||||||
|
"attributes": product.attributes or [],
|
||||||
|
"complex_attributes": product.complex_attributes or []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端(待实现)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// studio/src/pages/product/components/PublishPanel.tsx
|
||||||
|
|
||||||
|
import { Button, Select, message } from 'antd';
|
||||||
|
|
||||||
|
export function PublishPanel({ productId }) {
|
||||||
|
const [shops, setShops] = useState([]);
|
||||||
|
const [publishing, setPublishing] = useState(false);
|
||||||
|
|
||||||
|
const handlePublish = async (shopId) => {
|
||||||
|
setPublishing(true);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/products/${productId}/publish`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ shop_id: shopId })
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
message.success('发布任务已提交,轮询中...');
|
||||||
|
|
||||||
|
// 轮询状态(或 WebSocket 推送)
|
||||||
|
pollPublishStatus(data.task_id);
|
||||||
|
} catch (err) {
|
||||||
|
message.error(`发布失败: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setPublishing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Select
|
||||||
|
placeholder="选择目标店铺"
|
||||||
|
options={shops.map(s => ({ label: s.name, value: s.id }))}
|
||||||
|
onChange={handlePublish}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
loading={publishing}
|
||||||
|
onClick={() => /* trigger select */}
|
||||||
|
>
|
||||||
|
发布到 Ozon
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [02-category-tree.md](./02-category-tree.md) —— 获取类目
|
||||||
|
- [03-category-attributes.md](./03-category-attributes.md) —— 获取属性
|
||||||
|
- [05-product-info.md](./05-product-info.md) —— 查询商品详情
|
||||||
|
- [09-stocks.md](./09-stocks.md) —— 设置库存(必须)
|
||||||
|
- [docs/v2/ozon-publish.md](../v2/ozon-publish.md) —— 发布集成方案
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
# 商品信息查询 API
|
||||||
|
|
||||||
|
> 官方文档:https://docs.ozon.ru/api/seller/zh/#operation/ProductAPI_GetProductInfoListV3
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 接口信息
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 方法 | POST |
|
||||||
|
| 路径 | `/v3/product/info/list` |
|
||||||
|
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
|
||||||
|
| 用途 | 查询商品详细信息(含审核状态、图片、属性、错误) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 请求
|
||||||
|
|
||||||
|
### 请求体
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"offer_id": ["MY-THERMOS-001", "MY-THERMOS-002"],
|
||||||
|
"product_id": [987654321],
|
||||||
|
"sku": [123456789]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 参数说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| offer_id | array | 可选 | 自己的货号数组(最多 100 个) |
|
||||||
|
| product_id | array | 可选 | Ozon 商品 ID 数组(最多 100 个) |
|
||||||
|
| sku | array | 可选 | Ozon SKU 数组(最多 100 个) |
|
||||||
|
|
||||||
|
⚠️ **至少提供一个筛选条件**(offer_id / product_id / sku)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 响应
|
||||||
|
|
||||||
|
### 成功响应(200)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": 987654321,
|
||||||
|
"name": "Термокружка Thermos из нержавеющей стали 500 мл",
|
||||||
|
"offer_id": "MY-THERMOS-001",
|
||||||
|
"barcode": "",
|
||||||
|
"buybox_price": "2990.00",
|
||||||
|
"category_id": 17033876,
|
||||||
|
"created_at": "2024-08-10T10:30:00Z",
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"file_name": "thermos-main-1.jpg",
|
||||||
|
"default": true,
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"marketing_price": "2990.00",
|
||||||
|
"min_price": "2690.00",
|
||||||
|
"old_price": "3490.00",
|
||||||
|
"premium_price": "2790.00",
|
||||||
|
"price": "2990.00",
|
||||||
|
"recommended_price": "2990.00",
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"is_enabled": true,
|
||||||
|
"sku": 123456789,
|
||||||
|
"source": "fbs"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"state": "processed",
|
||||||
|
"stocks": {
|
||||||
|
"coming": 0,
|
||||||
|
"present": 100,
|
||||||
|
"reserved": 5
|
||||||
|
},
|
||||||
|
"errors": [],
|
||||||
|
"vat": "0.00",
|
||||||
|
"visible": true,
|
||||||
|
"visibility_details": {
|
||||||
|
"has_price": true,
|
||||||
|
"has_stock": true,
|
||||||
|
"active_product": true
|
||||||
|
},
|
||||||
|
"price_index": "5.0",
|
||||||
|
"images360": [],
|
||||||
|
"color_image": "",
|
||||||
|
"primary_image": "",
|
||||||
|
"status": {
|
||||||
|
"state": "processed",
|
||||||
|
"state_failed": "",
|
||||||
|
"moderate_status": "approved",
|
||||||
|
"decline_reasons": [],
|
||||||
|
"validation_state": "success",
|
||||||
|
"state_name": "Processed",
|
||||||
|
"state_description": "Product is processed",
|
||||||
|
"is_failed": false,
|
||||||
|
"is_created": true,
|
||||||
|
"state_tooltip": ""
|
||||||
|
},
|
||||||
|
"description_category_id": 17033876,
|
||||||
|
"type_id": 97114,
|
||||||
|
"width": 80,
|
||||||
|
"height": 200,
|
||||||
|
"depth": 80,
|
||||||
|
"dimension_unit": "mm",
|
||||||
|
"weight": 320,
|
||||||
|
"weight_unit": "g",
|
||||||
|
"attributes": [
|
||||||
|
{
|
||||||
|
"attribute_id": 85,
|
||||||
|
"complex_id": 0,
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"dictionary_value_id": 971082156,
|
||||||
|
"value": "Thermos"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 核心字段说明
|
||||||
|
|
||||||
|
### 基本信息
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| id | integer | Ozon 商品 ID(`product_id`) |
|
||||||
|
| name | string | 商品名称 |
|
||||||
|
| offer_id | string | 自己的货号 |
|
||||||
|
| barcode | string | 条形码 |
|
||||||
|
| created_at | string | 创建时间(ISO 8601) |
|
||||||
|
|
||||||
|
### 价格
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| price | string | 当前售价 |
|
||||||
|
| old_price | string | 划线价(原价) |
|
||||||
|
| marketing_price | string | 营销价 |
|
||||||
|
| buybox_price | string | BuyBox 价格(赢得购物车的价格) |
|
||||||
|
| recommended_price | string | 平台推荐价 |
|
||||||
|
| min_price | string | 允许的最低价(低于此价需申请) |
|
||||||
|
| premium_price | string | Premium 会员价 |
|
||||||
|
|
||||||
|
### 状态
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| **state** | string | **商品状态**(见下表) |
|
||||||
|
| **status** | object | **状态详情**(含审核状态、错误原因) |
|
||||||
|
| visible | boolean | 是否可见(上架) |
|
||||||
|
| visibility_details | object | 可见性详情(是否有价格/库存/激活) |
|
||||||
|
|
||||||
|
#### state 状态值
|
||||||
|
|
||||||
|
| state | 含义 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| **processed** | ✅ 已处理 | 商品创建成功,可正常展示 |
|
||||||
|
| **processing** | ⏳ 处理中 | 正在处理(刚导入) |
|
||||||
|
| **moderating** | ⏳ 审核中 | 平台审核中 |
|
||||||
|
| **failed** | ❌ 失败 | 创建/审核失败,查看 `errors` |
|
||||||
|
| **archived** | 📦 已归档 | 商品已下架归档 |
|
||||||
|
|
||||||
|
#### status.moderate_status 审核状态
|
||||||
|
|
||||||
|
| moderate_status | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| **approved** | ✅ 审核通过 |
|
||||||
|
| **pending** | ⏳ 待审核 |
|
||||||
|
| **declined** | ❌ 审核拒绝 |
|
||||||
|
|
||||||
|
### 库存
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| stocks.present | integer | 当前库存 |
|
||||||
|
| stocks.reserved | integer | 已预订数量 |
|
||||||
|
| stocks.coming | integer | 即将到货数量 |
|
||||||
|
|
||||||
|
### 图片
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| images | array | 图片数组 |
|
||||||
|
| images[].file_name | string | 图片文件名 |
|
||||||
|
| images[].default | boolean | 是否主图 |
|
||||||
|
| images[].index | integer | 顺序 |
|
||||||
|
| primary_image | string | 主图 URL |
|
||||||
|
| images360 | array | 360° 图 |
|
||||||
|
| color_image | string | 营销色图 |
|
||||||
|
|
||||||
|
### 尺寸与属性
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| description_category_id | integer | 类目 ID |
|
||||||
|
| type_id | integer | 商品类型 ID |
|
||||||
|
| width / height / depth | number | 尺寸 |
|
||||||
|
| dimension_unit | string | 尺寸单位 |
|
||||||
|
| weight | number | 重量 |
|
||||||
|
| weight_unit | string | 重量单位 |
|
||||||
|
| attributes | array | 属性数组(结构同导入) |
|
||||||
|
|
||||||
|
### 错误信息
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| errors | array | 错误数组(审核失败原因、字段错误等) |
|
||||||
|
| status.decline_reasons | array | 审核拒绝原因 |
|
||||||
|
| status.validation_state | string | 校验状态:`success` / `failed` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 使用场景
|
||||||
|
|
||||||
|
### 场景 1:发布后回查 product_id
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 导入后用 offer_id 查询,获取 product_id
|
||||||
|
async def get_product_id_by_offer(offer_id: str):
|
||||||
|
resp = await ozon_client.get_product_info(
|
||||||
|
offer_id=[offer_id]
|
||||||
|
)
|
||||||
|
items = resp["result"]["items"]
|
||||||
|
if items:
|
||||||
|
return items[0]["id"]
|
||||||
|
return None
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 2:检查审核状态
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def check_moderation_status(product_id: int):
|
||||||
|
resp = await ozon_client.get_product_info(
|
||||||
|
product_id=[product_id]
|
||||||
|
)
|
||||||
|
item = resp["result"]["items"][0]
|
||||||
|
status = item["status"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"state": status["state"],
|
||||||
|
"moderate_status": status["moderate_status"],
|
||||||
|
"is_approved": status["moderate_status"] == "approved",
|
||||||
|
"decline_reasons": status["decline_reasons"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 3:读取审核错误
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def get_product_errors(offer_id: str):
|
||||||
|
resp = await ozon_client.get_product_info(offer_id=[offer_id])
|
||||||
|
item = resp["result"]["items"][0]
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# 字段错误
|
||||||
|
if item.get("errors"):
|
||||||
|
errors.extend(item["errors"])
|
||||||
|
|
||||||
|
# 审核拒绝原因
|
||||||
|
if item["status"].get("decline_reasons"):
|
||||||
|
errors.extend(item["status"]["decline_reasons"])
|
||||||
|
|
||||||
|
return errors
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 错误处理
|
||||||
|
|
||||||
|
### 商品不存在
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
返回空数组,非 404 错误。
|
||||||
|
|
||||||
|
### 部分成功
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": 987654321,
|
||||||
|
"offer_id": "EXISTING-001",
|
||||||
|
/* ... 完整信息 */
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
请求 3 个 offer_id,只有 1 个存在 → 只返回 1 个 item。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 与其他接口的关系
|
||||||
|
|
||||||
|
### 与 `/v3/product/import` 的配合
|
||||||
|
|
||||||
|
```
|
||||||
|
① POST /v3/product/import → task_id
|
||||||
|
② POST /v1/product/import/info → status=imported, product_id=X
|
||||||
|
③ POST /v3/product/info/list (product_id=X) → 读取完整信息(含图片/审核状态)
|
||||||
|
```
|
||||||
|
|
||||||
|
**用途**:导入后可能需要:
|
||||||
|
- 确认图片上传成功
|
||||||
|
- 检查审核状态
|
||||||
|
- 读取 Ozon 生成的 SKU
|
||||||
|
- 查看价格索引(`price_index`,影响排名)
|
||||||
|
|
||||||
|
### 与 `/v3/product/list` 的区别
|
||||||
|
|
||||||
|
| 接口 | 用途 | 返回字段 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/v3/product/info/list` | **详情查询** | 完整字段(图片/属性/状态/错误) |
|
||||||
|
| `/v3/product/list` | **列表分页** | 基本字段(id/name/price/state),支持筛选/排序 |
|
||||||
|
|
||||||
|
**选择建议**:
|
||||||
|
- 已知 offer_id/product_id,要完整信息 → 用 `info/list`
|
||||||
|
- 分页浏览所有商品、筛选状态 → 用 `list`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 示例代码
|
||||||
|
|
||||||
|
### Python(服务端)
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def fetch_product_detail(
|
||||||
|
client_id: str,
|
||||||
|
api_key: str,
|
||||||
|
offer_id: str = None,
|
||||||
|
product_id: int = None
|
||||||
|
):
|
||||||
|
"""查询商品详情"""
|
||||||
|
headers = {
|
||||||
|
"Client-Id": client_id,
|
||||||
|
"Api-Key": api_key,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {}
|
||||||
|
if offer_id:
|
||||||
|
payload["offer_id"] = [offer_id]
|
||||||
|
if product_id:
|
||||||
|
payload["product_id"] = [product_id]
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"https://api-seller.ozon.ru/v3/product/info/list",
|
||||||
|
headers=headers,
|
||||||
|
json=payload
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
items = data.get("result", {}).get("items", [])
|
||||||
|
return items[0] if items else None
|
||||||
|
```
|
||||||
|
|
||||||
|
### TypeScript(前端)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function getProductDetail(
|
||||||
|
productId: string,
|
||||||
|
by: 'offer_id' | 'product_id' = 'offer_id'
|
||||||
|
) {
|
||||||
|
const resp = await fetch('/api/products/detail', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
[by]: [productId]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
return data.result.items[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用
|
||||||
|
const detail = await getProductDetail('MY-THERMOS-001', 'offer_id');
|
||||||
|
console.log('审核状态:', detail.status.moderate_status);
|
||||||
|
console.log('库存:', detail.stocks.present);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 审核拒绝原因解读
|
||||||
|
|
||||||
|
### 常见拒绝原因
|
||||||
|
|
||||||
|
| decline_reason | 含义 | 解决 |
|
||||||
|
|---|---|---|
|
||||||
|
| 图片不符合要求 | 图片非白底/有水印/模糊 | 重新上传符合规范的图片 |
|
||||||
|
| 标题含禁用词 | 标题有夸大宣传/品牌侵权词 | 修改标题,去除违规词 |
|
||||||
|
| 描述不完整 | 描述过短或缺少关键信息 | 补充完整商品描述 |
|
||||||
|
| 类目错误 | 商品与类目不匹配 | 重新选择正确类目 |
|
||||||
|
| 属性缺失 | 缺少必填属性 | 补充必填属性 |
|
||||||
|
| 品牌未授权 | 品牌需授权认证 | 提供品牌授权书或改用无品牌 |
|
||||||
|
|
||||||
|
### 处理流程
|
||||||
|
|
||||||
|
```
|
||||||
|
① 读取 status.decline_reasons
|
||||||
|
② 根据原因修改商品(改图/改文案/改属性)
|
||||||
|
③ 重新调用 /v3/product/import(同 offer_id = 更新)
|
||||||
|
④ 再次审核
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. V2 项目集成
|
||||||
|
|
||||||
|
### API 层(待实现)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# server/api/products.py
|
||||||
|
|
||||||
|
@router.get("/products/{product_id}/ozon-detail")
|
||||||
|
async def get_ozon_detail(
|
||||||
|
product_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""查询商品在 Ozon 的详情(审核状态/库存/图片)"""
|
||||||
|
product = await get_product(db, product_id)
|
||||||
|
|
||||||
|
if not product.ozon_product_id and not product.offer_id:
|
||||||
|
raise HTTPException(404, "商品尚未发布到 Ozon")
|
||||||
|
|
||||||
|
# 获取店铺凭证(从发布记录找)
|
||||||
|
task = await db.execute(
|
||||||
|
select(PublishTask)
|
||||||
|
.where(PublishTask.product_id == product_id)
|
||||||
|
.order_by(PublishTask.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
task = task.scalar_one_or_none()
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(404, "未找到发布记录")
|
||||||
|
|
||||||
|
shop = await get_shop(db, task.shop_id)
|
||||||
|
|
||||||
|
# 调用 Ozon API
|
||||||
|
detail = await ozon_client.get_product_info(
|
||||||
|
shop.client_id_dec,
|
||||||
|
shop.api_key_dec,
|
||||||
|
offer_id=[product.offer_id] if product.offer_id else None,
|
||||||
|
product_id=[product.ozon_product_id] if product.ozon_product_id else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"result": detail}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端(商品详情页展示审核状态)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// studio/src/pages/product/components/OzonStatusBadge.tsx
|
||||||
|
|
||||||
|
import { Badge, Tooltip } from 'antd';
|
||||||
|
|
||||||
|
export function OzonStatusBadge({ productId }) {
|
||||||
|
const [status, setStatus] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/products/${productId}/ozon-detail`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const item = data.result.items[0];
|
||||||
|
setStatus(item.status);
|
||||||
|
});
|
||||||
|
}, [productId]);
|
||||||
|
|
||||||
|
if (!status) return null;
|
||||||
|
|
||||||
|
const statusMap = {
|
||||||
|
approved: { color: 'success', text: '审核通过' },
|
||||||
|
pending: { color: 'processing', text: '审核中' },
|
||||||
|
declined: { color: 'error', text: '审核拒绝' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = statusMap[status.moderate_status] || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title={status.decline_reasons?.join(', ')}>
|
||||||
|
<Badge status={config.color} text={config.text} />
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 性能优化
|
||||||
|
|
||||||
|
### 批量查询
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 一次查询多个商品(最多 100 个)
|
||||||
|
async def batch_get_products(offer_ids: list[str]):
|
||||||
|
resp = await ozon_client.get_product_info(offer_id=offer_ids)
|
||||||
|
return {
|
||||||
|
item["offer_id"]: item
|
||||||
|
for item in resp["result"]["items"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# 使用
|
||||||
|
details = await batch_get_products([
|
||||||
|
"PROD-001", "PROD-002", "PROD-003"
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
### 缓存策略
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 商品详情变化不频繁,可短期缓存
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1000)
|
||||||
|
async def get_product_info_cached(offer_id: str, ttl=300):
|
||||||
|
# TTL 5 分钟
|
||||||
|
detail = await ozon_client.get_product_info(offer_id=[offer_id])
|
||||||
|
return detail["result"]["items"][0] if detail["result"]["items"] else None
|
||||||
|
|
||||||
|
# 审核状态变化时清缓存
|
||||||
|
get_product_info_cached.cache_clear()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [04-product-import.md](./04-product-import.md) —— 创建/更新商品
|
||||||
|
- [06-product-list.md](./06-product-list.md) —— 商品列表分页
|
||||||
|
- [09-stocks.md](./09-stocks.md) —— 库存管理
|
||||||
|
- [docs/v2/ozon-publish.md](../v2/ozon-publish.md) —— 发布集成方案
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
# 商品列表查询 API
|
||||||
|
|
||||||
|
> 官方文档:https://docs.ozon.ru/api/seller/zh/#operation/ProductAPI_GetProductListV3
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 接口信息
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 方法 | POST |
|
||||||
|
| 路径 | `/v3/product/list` |
|
||||||
|
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
|
||||||
|
| 用途 | 分页查询商品列表(支持筛选、排序) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 请求
|
||||||
|
|
||||||
|
### 请求体
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filter": {
|
||||||
|
"offer_id": ["MY-THERMOS-001"],
|
||||||
|
"product_id": [987654321],
|
||||||
|
"visibility": "ALL"
|
||||||
|
},
|
||||||
|
"last_id": "",
|
||||||
|
"limit": 100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 参数说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| filter | object | 可选 | 筛选条件 |
|
||||||
|
| filter.offer_id | array | 可选 | 货号数组 |
|
||||||
|
| filter.product_id | array | 可选 | Ozon 商品 ID 数组 |
|
||||||
|
| filter.visibility | string | 可选 | 可见性:`ALL`(全部)/ `VISIBLE`(可见)/ `INVISIBLE`(不可见)/ `EMPTY_STOCK`(无库存)。默认 `ALL` |
|
||||||
|
| **last_id** | string | 可选 | **分页游标**(上一页最后一个商品的 ID),首页传空字符串 `""` |
|
||||||
|
| **limit** | integer | 可选 | 每页数量,最大 **1000**,默认 100 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 响应
|
||||||
|
|
||||||
|
### 成功响应(200)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"product_id": 987654321,
|
||||||
|
"offer_id": "MY-THERMOS-001"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"product_id": 987654322,
|
||||||
|
"offer_id": "MY-THERMOS-002"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 256,
|
||||||
|
"last_id": "bnVtYmVyMjo5ODc2NTQzMjI="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| items | array | 商品列表(**仅基本字段**:product_id + offer_id) |
|
||||||
|
| total | integer | 商品总数 |
|
||||||
|
| **last_id** | string | **下一页游标**(Base64 编码,传给下次请求) |
|
||||||
|
|
||||||
|
⚠️ **注意**:此接口**仅返回 product_id 和 offer_id**,不返回名称/价格/图片等详情。要获取完整信息,需再调 `/v3/product/info/list`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 分页示例
|
||||||
|
|
||||||
|
### 游标分页(推荐)
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def fetch_all_products(visibility="ALL"):
|
||||||
|
"""拉取所有商品(游标分页)"""
|
||||||
|
all_items = []
|
||||||
|
last_id = ""
|
||||||
|
|
||||||
|
while True:
|
||||||
|
resp = await ozon_client.get_product_list(
|
||||||
|
filter={"visibility": visibility},
|
||||||
|
last_id=last_id,
|
||||||
|
limit=1000 # 单次最多 1000
|
||||||
|
)
|
||||||
|
|
||||||
|
items = resp["result"]["items"]
|
||||||
|
all_items.extend(items)
|
||||||
|
|
||||||
|
# 无更多数据
|
||||||
|
if not resp["result"].get("last_id"):
|
||||||
|
break
|
||||||
|
|
||||||
|
last_id = resp["result"]["last_id"]
|
||||||
|
|
||||||
|
return all_items
|
||||||
|
```
|
||||||
|
|
||||||
|
### 分批处理
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def process_products_in_batches(batch_size=100):
|
||||||
|
"""分批处理商品(避免一次拉全部)"""
|
||||||
|
last_id = ""
|
||||||
|
|
||||||
|
while True:
|
||||||
|
resp = await ozon_client.get_product_list(
|
||||||
|
last_id=last_id,
|
||||||
|
limit=batch_size
|
||||||
|
)
|
||||||
|
|
||||||
|
items = resp["result"]["items"]
|
||||||
|
if not items:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 处理当前批次
|
||||||
|
await process_batch(items)
|
||||||
|
|
||||||
|
last_id = resp["result"].get("last_id")
|
||||||
|
if not last_id:
|
||||||
|
break
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 获取完整信息
|
||||||
|
|
||||||
|
### 方法 A:批量查详情(推荐)
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def fetch_products_with_detail(visibility="ALL"):
|
||||||
|
"""拉取商品列表 + 完整信息"""
|
||||||
|
# 1. 拉列表(仅 ID)
|
||||||
|
list_resp = await ozon_client.get_product_list(
|
||||||
|
filter={"visibility": visibility},
|
||||||
|
limit=1000
|
||||||
|
)
|
||||||
|
|
||||||
|
items = list_resp["result"]["items"]
|
||||||
|
product_ids = [item["product_id"] for item in items]
|
||||||
|
|
||||||
|
# 2. 批量查详情(每次最多 100 个)
|
||||||
|
details = []
|
||||||
|
for i in range(0, len(product_ids), 100):
|
||||||
|
batch = product_ids[i:i+100]
|
||||||
|
detail_resp = await ozon_client.get_product_info(
|
||||||
|
product_id=batch
|
||||||
|
)
|
||||||
|
details.extend(detail_resp["result"]["items"])
|
||||||
|
|
||||||
|
return details
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方法 B:按需查详情
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 先列表,用户点击某个商品时再查详情
|
||||||
|
products = await ozon_client.get_product_list(limit=100)
|
||||||
|
|
||||||
|
# 用户点击 product_id=987654321
|
||||||
|
detail = await ozon_client.get_product_info(product_id=[987654321])
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 筛选条件详解
|
||||||
|
|
||||||
|
### visibility 筛选
|
||||||
|
|
||||||
|
| 值 | 含义 | 使用场景 |
|
||||||
|
|---|---|---|
|
||||||
|
| **ALL** | 全部商品 | 管理后台(查看所有) |
|
||||||
|
| **VISIBLE** | 可见商品(上架) | 前台展示的商品 |
|
||||||
|
| **INVISIBLE** | 不可见商品(下架/草稿) | 待上架/审核失败/归档 |
|
||||||
|
| **EMPTY_STOCK** | 无库存商品 | 补货提醒 |
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 查询所有上架商品
|
||||||
|
visible = await ozon_client.get_product_list(
|
||||||
|
filter={"visibility": "VISIBLE"},
|
||||||
|
limit=1000
|
||||||
|
)
|
||||||
|
|
||||||
|
# 查询无库存商品(需补货)
|
||||||
|
empty_stock = await ozon_client.get_product_list(
|
||||||
|
filter={"visibility": "EMPTY_STOCK"},
|
||||||
|
limit=100
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 性能对比
|
||||||
|
|
||||||
|
### `/v3/product/list` vs `/v3/product/info/list`
|
||||||
|
|
||||||
|
| 维度 | `/v3/product/list` | `/v3/product/info/list` |
|
||||||
|
|---|---|---|
|
||||||
|
| 返回字段 | 仅 product_id + offer_id | 完整字段(图片/属性/状态) |
|
||||||
|
| 单次数量 | 最多 **1000** | 最多 **100** |
|
||||||
|
| 响应速度 | 快(字段少) | 慢(字段多) |
|
||||||
|
| 适用场景 | 列表/分页/ID 收集 | 详情查询/更新前读取 |
|
||||||
|
|
||||||
|
**策略**:
|
||||||
|
1. 先用 `/list` 拉 ID 列表(快)
|
||||||
|
2. 再用 `/info/list` 批量查详情(按需,100 个一批)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 与本地数据库同步
|
||||||
|
|
||||||
|
### 场景:回填 product_id
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def sync_product_ids():
|
||||||
|
"""发布后回填 product_id(用 offer_id 匹配)"""
|
||||||
|
# 1. 从数据库取所有「已发布但无 product_id」的商品
|
||||||
|
local_products = await db.execute(
|
||||||
|
select(Product)
|
||||||
|
.where(
|
||||||
|
Product.stage == "published",
|
||||||
|
Product.ozon_product_id.is_(None),
|
||||||
|
Product.offer_id.isnot(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
local_products = local_products.scalars().all()
|
||||||
|
|
||||||
|
if not local_products:
|
||||||
|
return
|
||||||
|
|
||||||
|
offer_ids = [p.offer_id for p in local_products]
|
||||||
|
|
||||||
|
# 2. 从 Ozon 查询这些 offer_id 的 product_id
|
||||||
|
ozon_items = []
|
||||||
|
for i in range(0, len(offer_ids), 100):
|
||||||
|
batch = offer_ids[i:i+100]
|
||||||
|
resp = await ozon_client.get_product_info(offer_id=batch)
|
||||||
|
ozon_items.extend(resp["result"]["items"])
|
||||||
|
|
||||||
|
# 3. 回填到数据库
|
||||||
|
ozon_map = {item["offer_id"]: item["id"] for item in ozon_items}
|
||||||
|
|
||||||
|
for p in local_products:
|
||||||
|
if p.offer_id in ozon_map:
|
||||||
|
p.ozon_product_id = ozon_map[p.offer_id]
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景:定期同步状态
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def sync_product_states():
|
||||||
|
"""定期同步商品状态(审核状态/库存/可见性)"""
|
||||||
|
# 1. 拉取所有 Ozon 商品 ID
|
||||||
|
ozon_resp = await ozon_client.get_product_list(limit=1000)
|
||||||
|
ozon_ids = [item["product_id"] for item in ozon_resp["result"]["items"]]
|
||||||
|
|
||||||
|
# 2. 批量查详情
|
||||||
|
details = []
|
||||||
|
for i in range(0, len(ozon_ids), 100):
|
||||||
|
batch = ozon_ids[i:i+100]
|
||||||
|
resp = await ozon_client.get_product_info(product_id=batch)
|
||||||
|
details.extend(resp["result"]["items"])
|
||||||
|
|
||||||
|
# 3. 更新本地数据库
|
||||||
|
for item in details:
|
||||||
|
await db.execute(
|
||||||
|
update(Product)
|
||||||
|
.where(Product.ozon_product_id == item["id"])
|
||||||
|
.values(
|
||||||
|
stage="published" if item["visible"] else "failed",
|
||||||
|
# 可同步更多字段:价格/库存/审核状态
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 常见问题
|
||||||
|
|
||||||
|
### Q1: 为什么 `/list` 只返回 ID?
|
||||||
|
|
||||||
|
**A**: 性能考虑。商品列表可能有**数万条**,返回完整字段会很慢。设计思路:
|
||||||
|
1. 先快速拉 ID 列表(轻量)
|
||||||
|
2. 前端展示分页,只查当前页的详情
|
||||||
|
3. 或后台批量拉详情,按需处理
|
||||||
|
|
||||||
|
### Q2: 如何获取商品总数?
|
||||||
|
|
||||||
|
**A**: 响应的 `total` 字段。但注意:
|
||||||
|
- `total` 是当前筛选条件下的总数
|
||||||
|
- 不保证精确(可能略有延迟)
|
||||||
|
|
||||||
|
### Q3: 游标分页与偏移分页的区别?
|
||||||
|
|
||||||
|
**A**:
|
||||||
|
- **游标分页**(last_id):适合全量遍历,性能稳定
|
||||||
|
- **偏移分页**(offset):Ozon 不支持(无 offset 参数)
|
||||||
|
|
||||||
|
### Q4: 多久同步一次?
|
||||||
|
|
||||||
|
**A**: 建议策略:
|
||||||
|
- 发布后立即查询(回填 product_id)
|
||||||
|
- 定期同步(每天一次,更新状态/库存)
|
||||||
|
- 用户主动刷新(按需实时查询)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. V2 项目集成
|
||||||
|
|
||||||
|
### API 层(待实现)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# server/api/products.py
|
||||||
|
|
||||||
|
@router.get("/products/sync-from-ozon")
|
||||||
|
async def sync_from_ozon(
|
||||||
|
shop_id: str = Query(...),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""从 Ozon 同步商品列表(回填 product_id + 状态)"""
|
||||||
|
shop = await get_shop(db, shop_id)
|
||||||
|
|
||||||
|
# 1. 拉取 Ozon 商品列表
|
||||||
|
ozon_items = []
|
||||||
|
last_id = ""
|
||||||
|
|
||||||
|
while True:
|
||||||
|
resp = await ozon_client.get_product_list(
|
||||||
|
shop.client_id_dec,
|
||||||
|
shop.api_key_dec,
|
||||||
|
last_id=last_id,
|
||||||
|
limit=1000
|
||||||
|
)
|
||||||
|
|
||||||
|
items = resp["result"]["items"]
|
||||||
|
ozon_items.extend(items)
|
||||||
|
|
||||||
|
last_id = resp["result"].get("last_id")
|
||||||
|
if not last_id:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 2. 批量查详情
|
||||||
|
product_ids = [item["product_id"] for item in ozon_items]
|
||||||
|
details = []
|
||||||
|
|
||||||
|
for i in range(0, len(product_ids), 100):
|
||||||
|
batch = product_ids[i:i+100]
|
||||||
|
detail_resp = await ozon_client.get_product_info(
|
||||||
|
shop.client_id_dec,
|
||||||
|
shop.api_key_dec,
|
||||||
|
product_id=batch
|
||||||
|
)
|
||||||
|
details.extend(detail_resp["result"]["items"])
|
||||||
|
|
||||||
|
# 3. 更新本地数据库
|
||||||
|
updated = 0
|
||||||
|
for item in details:
|
||||||
|
result = await db.execute(
|
||||||
|
update(Product)
|
||||||
|
.where(Product.offer_id == item["offer_id"])
|
||||||
|
.values(
|
||||||
|
ozon_product_id=item["id"],
|
||||||
|
stage="published" if item["visible"] else "archived"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
updated += result.rowcount
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"synced": len(details),
|
||||||
|
"updated": updated
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端(待实现)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// studio/src/pages/products/SyncButton.tsx
|
||||||
|
|
||||||
|
import { Button, message } from 'antd';
|
||||||
|
import { SyncOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
export function SyncFromOzonButton({ shopId }) {
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
|
||||||
|
const handleSync = async () => {
|
||||||
|
setSyncing(true);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`/api/products/sync-from-ozon?shop_id=${shopId}`
|
||||||
|
);
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
message.success(
|
||||||
|
`已同步 ${data.synced} 个商品,更新 ${data.updated} 条记录`
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
message.error(`同步失败: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
icon={<SyncOutlined />}
|
||||||
|
loading={syncing}
|
||||||
|
onClick={handleSync}
|
||||||
|
>
|
||||||
|
从 Ozon 同步
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 高级用法
|
||||||
|
|
||||||
|
### 增量同步
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def incremental_sync(last_sync_time: datetime):
|
||||||
|
"""增量同步:只拉取最近更新的商品"""
|
||||||
|
# Ozon 的 /list 接口不支持按更新时间筛选
|
||||||
|
# 策略:全量拉 ID,对比本地 updated_at,只查变化的
|
||||||
|
|
||||||
|
ozon_items = await fetch_all_products()
|
||||||
|
ozon_ids = {item["product_id"] for item in ozon_items}
|
||||||
|
|
||||||
|
# 查本地已有的 product_id
|
||||||
|
local = await db.execute(
|
||||||
|
select(Product.ozon_product_id, Product.updated_at)
|
||||||
|
.where(Product.ozon_product_id.isnot(None))
|
||||||
|
)
|
||||||
|
local_map = {
|
||||||
|
row.ozon_product_id: row.updated_at
|
||||||
|
for row in local.fetchall()
|
||||||
|
}
|
||||||
|
|
||||||
|
# 找出新增的 ID
|
||||||
|
new_ids = ozon_ids - set(local_map.keys())
|
||||||
|
|
||||||
|
# 批量查详情(只查新增的)
|
||||||
|
if new_ids:
|
||||||
|
details = await batch_get_product_info(list(new_ids))
|
||||||
|
# 插入数据库
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [04-product-import.md](./04-product-import.md) —— 创建/更新商品
|
||||||
|
- [05-product-info.md](./05-product-info.md) —— 查询商品详情
|
||||||
|
- [09-stocks.md](./09-stocks.md) —— 库存管理
|
||||||
|
- [docs/v2/database.md](../v2/database.md) —— products 表结构
|
||||||
@@ -0,0 +1,599 @@
|
|||||||
|
# 库存管理 API
|
||||||
|
|
||||||
|
> 官方文档:https://docs.ozon.ru/api/seller/zh/#operation/ProductAPI_ProductsStocksV2
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 接口信息
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 方法 | POST |
|
||||||
|
| 路径 | `/v2/products/stocks` |
|
||||||
|
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
|
||||||
|
| 用途 | **设置/更新商品库存**(必须操作,否则商品不可购买) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 重要约束
|
||||||
|
|
||||||
|
⚠️ **商品导入成功后不会自动上架**,必须设置库存才能开售:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /v3/product/import → status=imported(商品已创建)
|
||||||
|
↓
|
||||||
|
POST /v2/products/stocks → 设置库存(商品可购买)
|
||||||
|
```
|
||||||
|
|
||||||
|
未设置库存的商品:
|
||||||
|
- ✅ 在卖家后台可见
|
||||||
|
- ❌ 前台不展示
|
||||||
|
- ❌ 无法购买
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 请求
|
||||||
|
|
||||||
|
### 请求体
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"stocks": [
|
||||||
|
{
|
||||||
|
"offer_id": "MY-THERMOS-001",
|
||||||
|
"product_id": 987654321,
|
||||||
|
"stock": 100,
|
||||||
|
"warehouse_id": 12345678
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"offer_id": "MY-THERMOS-002",
|
||||||
|
"stock": 50,
|
||||||
|
"warehouse_id": 12345678
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 参数说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| stocks | array | ✅ 是 | 库存数组(最多 **100** 个) |
|
||||||
|
| stocks[].offer_id | string | 条件 | 自己的货号(与 product_id 二选一) |
|
||||||
|
| stocks[].product_id | integer | 条件 | Ozon 商品 ID(与 offer_id 二选一) |
|
||||||
|
| stocks[].stock | integer | ✅ 是 | 库存数量。`0` = 无库存(下架) |
|
||||||
|
| stocks[].warehouse_id | integer | ✅ 是 | 仓库 ID(见下节) |
|
||||||
|
|
||||||
|
⚠️ **必须提供 offer_id 或 product_id**(建议用 offer_id,更稳定)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 仓库 ID(warehouse_id)
|
||||||
|
|
||||||
|
### 获取仓库 ID
|
||||||
|
|
||||||
|
**接口**:`POST /v1/warehouse/list`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"warehouse_id": 12345678,
|
||||||
|
"name": "FBS 仓库-莫斯科",
|
||||||
|
"can_print_act_in_advance": true,
|
||||||
|
"is_rfbs": false,
|
||||||
|
"has_postings_limit": false,
|
||||||
|
"postings_limit": 0,
|
||||||
|
"status": "working"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| warehouse_id | **仓库 ID**(设置库存时用) |
|
||||||
|
| name | 仓库名称 |
|
||||||
|
| is_rfbs | 是否 rFBS 仓库(Ozon 代发货) |
|
||||||
|
| status | 状态:`working`(运行中)/ `disabled`(禁用) |
|
||||||
|
|
||||||
|
### 仓库类型
|
||||||
|
|
||||||
|
| 类型 | 说明 | warehouse_id |
|
||||||
|
|---|---|---|
|
||||||
|
| **FBS** | 自发货(Fulfillment by Seller) | 从 `/v1/warehouse/list` 获取 |
|
||||||
|
| **FBO** | Ozon 发货(Fulfillment by Ozon) | 从 `/v1/warehouse/list` 获取 |
|
||||||
|
| **rFBS** | Ozon 代发货(类似 FBO,但库存在卖家处) | `is_rfbs=true` |
|
||||||
|
|
||||||
|
**推荐**:新商户优先用 **FBS**(自发货),灵活且门槛低。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 响应
|
||||||
|
|
||||||
|
### 成功响应(200)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"errors": [],
|
||||||
|
"offer_id": "MY-THERMOS-001",
|
||||||
|
"product_id": 987654321,
|
||||||
|
"updated": true,
|
||||||
|
"warehouse_id": 12345678
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"code": "PRODUCT_NOT_FOUND",
|
||||||
|
"message": "Product not found"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"offer_id": "MY-THERMOS-999",
|
||||||
|
"product_id": 0,
|
||||||
|
"updated": false,
|
||||||
|
"warehouse_id": 12345678
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| updated | boolean | 是否更新成功 |
|
||||||
|
| errors | array | 错误数组(失败时) |
|
||||||
|
| offer_id | string | 货号(回显) |
|
||||||
|
| product_id | integer | Ozon 商品 ID(回显) |
|
||||||
|
| warehouse_id | integer | 仓库 ID(回显) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 常见错误
|
||||||
|
|
||||||
|
| code | message | 原因 | 解决 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `PRODUCT_NOT_FOUND` | 商品不存在 | offer_id/product_id 错误或商品已删除 | 检查 ID 是否正确 |
|
||||||
|
| `WAREHOUSE_NOT_FOUND` | 仓库不存在 | warehouse_id 错误 | 调 `/v1/warehouse/list` 获取正确 ID |
|
||||||
|
| `INVALID_STOCK` | 库存值错误 | stock < 0 | 库存必须 ≥ 0 |
|
||||||
|
| `PRODUCT_ARCHIVED` | 商品已归档 | 商品处于归档状态 | 先恢复商品再设库存 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 使用场景
|
||||||
|
|
||||||
|
### 场景 1:发布后设置初始库存
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def publish_and_set_stock(product: Product, shop: Shop):
|
||||||
|
"""发布商品 + 设置库存(完整流程)"""
|
||||||
|
# 1. 导入商品
|
||||||
|
resp = await ozon_client.import_products(
|
||||||
|
shop.client_id_dec,
|
||||||
|
shop.api_key_dec,
|
||||||
|
[build_import_item(product)]
|
||||||
|
)
|
||||||
|
task_id = resp["result"]["task_id"]
|
||||||
|
|
||||||
|
# 2. 轮询直到成功
|
||||||
|
result = await wait_for_import(task_id)
|
||||||
|
if not result["success"]:
|
||||||
|
raise Exception(f"发布失败: {result['errors']}")
|
||||||
|
|
||||||
|
product_id = result["product_id"]
|
||||||
|
|
||||||
|
# 3. 获取仓库 ID
|
||||||
|
warehouses = await ozon_client.get_warehouses(
|
||||||
|
shop.client_id_dec,
|
||||||
|
shop.api_key_dec
|
||||||
|
)
|
||||||
|
warehouse_id = warehouses[0]["warehouse_id"] # 取第一个
|
||||||
|
|
||||||
|
# 4. 设置库存
|
||||||
|
stock_resp = await ozon_client.update_stocks(
|
||||||
|
shop.client_id_dec,
|
||||||
|
shop.api_key_dec,
|
||||||
|
[{
|
||||||
|
"product_id": product_id,
|
||||||
|
"stock": 100, # 初始库存
|
||||||
|
"warehouse_id": warehouse_id
|
||||||
|
}]
|
||||||
|
)
|
||||||
|
|
||||||
|
return stock_resp
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 2:批量更新库存
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def batch_update_stocks(updates: list[dict]):
|
||||||
|
"""批量更新库存(最多 100 个)"""
|
||||||
|
# updates = [
|
||||||
|
# {"offer_id": "PROD-001", "stock": 50},
|
||||||
|
# {"offer_id": "PROD-002", "stock": 0}, # 0 = 下架
|
||||||
|
# ]
|
||||||
|
|
||||||
|
warehouse_id = await get_default_warehouse_id()
|
||||||
|
|
||||||
|
stocks = [
|
||||||
|
{
|
||||||
|
"offer_id": u["offer_id"],
|
||||||
|
"stock": u["stock"],
|
||||||
|
"warehouse_id": warehouse_id
|
||||||
|
}
|
||||||
|
for u in updates
|
||||||
|
]
|
||||||
|
|
||||||
|
resp = await ozon_client.update_stocks(
|
||||||
|
client_id, api_key, stocks
|
||||||
|
)
|
||||||
|
|
||||||
|
# 检查失败项
|
||||||
|
failed = [
|
||||||
|
item for item in resp["result"]
|
||||||
|
if not item["updated"]
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": len(resp["result"]) - len(failed),
|
||||||
|
"failed": failed
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 3:库存为 0 时下架
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def out_of_stock(offer_id: str):
|
||||||
|
"""库存售罄,设为 0(自动下架)"""
|
||||||
|
await ozon_client.update_stocks(
|
||||||
|
client_id, api_key,
|
||||||
|
[{
|
||||||
|
"offer_id": offer_id,
|
||||||
|
"stock": 0, # 库存为 0 → 前台不展示
|
||||||
|
"warehouse_id": warehouse_id
|
||||||
|
}]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 4:补货后上架
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def restock(offer_id: str, quantity: int):
|
||||||
|
"""补货后重新上架"""
|
||||||
|
await ozon_client.update_stocks(
|
||||||
|
client_id, api_key,
|
||||||
|
[{
|
||||||
|
"offer_id": offer_id,
|
||||||
|
"stock": quantity, # 设置新库存 → 自动上架
|
||||||
|
"warehouse_id": warehouse_id
|
||||||
|
}]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 查询当前库存
|
||||||
|
|
||||||
|
### 接口信息
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 方法 | POST |
|
||||||
|
| 路径 | `/v3/product/info/stocks` |
|
||||||
|
| 用途 | 查询商品当前库存 |
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filter": {
|
||||||
|
"offer_id": ["MY-THERMOS-001"],
|
||||||
|
"product_id": [987654321],
|
||||||
|
"visibility": "ALL"
|
||||||
|
},
|
||||||
|
"last_id": "",
|
||||||
|
"limit": 100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"offer_id": "MY-THERMOS-001",
|
||||||
|
"product_id": 987654321,
|
||||||
|
"stocks": [
|
||||||
|
{
|
||||||
|
"type": "fbs",
|
||||||
|
"present": 100,
|
||||||
|
"reserved": 5,
|
||||||
|
"warehouse_id": 12345678,
|
||||||
|
"warehouse_name": "FBS 仓库-莫斯科"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"last_id": "",
|
||||||
|
"total": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| stocks[].present | 可用库存 |
|
||||||
|
| stocks[].reserved | 已预订数量(订单未完成) |
|
||||||
|
| stocks[].type | 仓库类型:`fbs` / `fbo` / `rfbs` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 库存同步策略
|
||||||
|
|
||||||
|
### 策略 A:实时同步(推荐)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 本地库存变化时立即更新 Ozon
|
||||||
|
async def on_local_stock_change(product_id: str, new_stock: int):
|
||||||
|
product = await get_product(db, product_id)
|
||||||
|
|
||||||
|
if not product.ozon_product_id:
|
||||||
|
return # 未发布到 Ozon
|
||||||
|
|
||||||
|
shop = await get_default_shop(db)
|
||||||
|
warehouse_id = await get_default_warehouse_id()
|
||||||
|
|
||||||
|
await ozon_client.update_stocks(
|
||||||
|
shop.client_id_dec,
|
||||||
|
shop.api_key_dec,
|
||||||
|
[{
|
||||||
|
"offer_id": product.offer_id,
|
||||||
|
"stock": new_stock,
|
||||||
|
"warehouse_id": warehouse_id
|
||||||
|
}]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 策略 B:定时同步
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 每天凌晨同步一次(防止偏差累积)
|
||||||
|
async def daily_sync_stocks():
|
||||||
|
"""定时任务:同步本地库存到 Ozon"""
|
||||||
|
products = await db.execute(
|
||||||
|
select(Product)
|
||||||
|
.where(
|
||||||
|
Product.stage == "published",
|
||||||
|
Product.ozon_product_id.isnot(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
products = products.scalars().all()
|
||||||
|
|
||||||
|
warehouse_id = await get_default_warehouse_id()
|
||||||
|
|
||||||
|
# 批量更新(100 个一批)
|
||||||
|
for i in range(0, len(products), 100):
|
||||||
|
batch = products[i:i+100]
|
||||||
|
stocks = [
|
||||||
|
{
|
||||||
|
"offer_id": p.offer_id,
|
||||||
|
"stock": p.local_stock, # 假设有 local_stock 字段
|
||||||
|
"warehouse_id": warehouse_id
|
||||||
|
}
|
||||||
|
for p in batch
|
||||||
|
]
|
||||||
|
|
||||||
|
await ozon_client.update_stocks(
|
||||||
|
client_id, api_key, stocks
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 策略 C:反向同步(从 Ozon 读回)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 定期从 Ozon 读回库存(多渠道销售时需要)
|
||||||
|
async def sync_stocks_from_ozon():
|
||||||
|
"""从 Ozon 同步库存到本地"""
|
||||||
|
resp = await ozon_client.get_product_stocks(
|
||||||
|
filter={"visibility": "VISIBLE"},
|
||||||
|
limit=1000
|
||||||
|
)
|
||||||
|
|
||||||
|
for item in resp["result"]["items"]:
|
||||||
|
offer_id = item["offer_id"]
|
||||||
|
ozon_stock = item["stocks"][0]["present"]
|
||||||
|
|
||||||
|
# 更新本地库存
|
||||||
|
await db.execute(
|
||||||
|
update(Product)
|
||||||
|
.where(Product.offer_id == offer_id)
|
||||||
|
.values(local_stock=ozon_stock)
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. V2 项目集成
|
||||||
|
|
||||||
|
### API 层(待实现)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# server/api/products.py
|
||||||
|
|
||||||
|
@router.post("/products/{product_id}/set-stock")
|
||||||
|
async def set_stock(
|
||||||
|
product_id: str,
|
||||||
|
stock: int = Body(..., ge=0),
|
||||||
|
shop_id: str = Body(...),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""设置商品库存"""
|
||||||
|
product = await get_product(db, product_id)
|
||||||
|
|
||||||
|
if not product.ozon_product_id and not product.offer_id:
|
||||||
|
raise HTTPException(400, "商品尚未发布到 Ozon")
|
||||||
|
|
||||||
|
shop = await get_shop(db, shop_id)
|
||||||
|
|
||||||
|
# 获取仓库 ID(缓存)
|
||||||
|
warehouse_id = await get_or_cache_warehouse_id(shop)
|
||||||
|
|
||||||
|
# 调用 Ozon API
|
||||||
|
resp = await ozon_client.update_stocks(
|
||||||
|
shop.client_id_dec,
|
||||||
|
shop.api_key_dec,
|
||||||
|
[{
|
||||||
|
"offer_id": product.offer_id,
|
||||||
|
"stock": stock,
|
||||||
|
"warehouse_id": warehouse_id
|
||||||
|
}]
|
||||||
|
)
|
||||||
|
|
||||||
|
result = resp["result"][0]
|
||||||
|
|
||||||
|
if not result["updated"]:
|
||||||
|
raise HTTPException(500, f"更新失败: {result['errors']}")
|
||||||
|
|
||||||
|
# 更新本地记录
|
||||||
|
product.local_stock = stock
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {"success": True, "stock": stock}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/shops/{shop_id}/warehouses")
|
||||||
|
async def get_warehouses(
|
||||||
|
shop_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取店铺的仓库列表"""
|
||||||
|
shop = await get_shop(db, shop_id)
|
||||||
|
|
||||||
|
warehouses = await ozon_client.get_warehouses(
|
||||||
|
shop.client_id_dec,
|
||||||
|
shop.api_key_dec
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"result": warehouses}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端(待实现)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// studio/src/pages/product/components/StockPanel.tsx
|
||||||
|
|
||||||
|
import { InputNumber, Button, message } from 'antd';
|
||||||
|
|
||||||
|
export function StockPanel({ productId, shopId }) {
|
||||||
|
const [stock, setStock] = useState(0);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await fetch(`/api/products/${productId}/set-stock`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ stock, shop_id: shopId })
|
||||||
|
});
|
||||||
|
|
||||||
|
message.success(`库存已设置为 ${stock}`);
|
||||||
|
} catch (err) {
|
||||||
|
message.error(`设置失败: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
value={stock}
|
||||||
|
onChange={setStock}
|
||||||
|
placeholder="库存数量"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
loading={saving}
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
设置库存
|
||||||
|
</Button>
|
||||||
|
<div style={{ marginTop: 8, fontSize: 12, color: '#888' }}>
|
||||||
|
💡 库存为 0 时商品自动下架
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 最佳实践
|
||||||
|
|
||||||
|
### 1. 发布流程中必须设库存
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ 正确:
|
||||||
|
POST /v3/product/import → 轮询成功 → POST /v2/products/stocks
|
||||||
|
|
||||||
|
❌ 错误:
|
||||||
|
POST /v3/product/import → 轮询成功 → 结束(商品不可购买)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 缓存仓库 ID
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 仓库 ID 不常变,启动时拉取并缓存
|
||||||
|
_warehouse_cache = {}
|
||||||
|
|
||||||
|
async def get_warehouse_id(shop_id: str):
|
||||||
|
if shop_id not in _warehouse_cache:
|
||||||
|
warehouses = await ozon_client.get_warehouses(...)
|
||||||
|
_warehouse_cache[shop_id] = warehouses[0]["warehouse_id"]
|
||||||
|
|
||||||
|
return _warehouse_cache[shop_id]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 库存为 0 的处理
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 库存为 0 → 自动下架,但商品仍在后台
|
||||||
|
# 补货后再设置库存 → 自动上架
|
||||||
|
# 不需要删除商品,只需更新库存
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 批量操作
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 单次最多 100 个,超过需分批
|
||||||
|
async def update_large_batch(stocks: list):
|
||||||
|
results = []
|
||||||
|
for i in range(0, len(stocks), 100):
|
||||||
|
batch = stocks[i:i+100]
|
||||||
|
resp = await ozon_client.update_stocks(client_id, api_key, batch)
|
||||||
|
results.extend(resp["result"])
|
||||||
|
|
||||||
|
return results
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [04-product-import.md](./04-product-import.md) —— 创建商品(发布前置)
|
||||||
|
- [05-product-info.md](./05-product-info.md) —— 查询商品信息(含库存)
|
||||||
|
- [10-prices.md](./10-prices.md) —— 价格更新
|
||||||
|
- [docs/v2/ozon-publish.md](../v2/ozon-publish.md) §5 —— 发布链路(含库存设置)
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# Ozon Seller API 文档总览
|
||||||
|
|
||||||
|
> 本目录整理 Ozon Seller API 的核心接口文档,供 ozon-seller-kit 项目集成使用。
|
||||||
|
> 官方文档:https://docs.ozon.ru/api/seller/zh/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
| 文档 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| [01-authentication.md](./01-authentication.md) | 鉴权方式、请求头、错误码 |
|
||||||
|
| [02-category-tree.md](./02-category-tree.md) | 类目树查询 |
|
||||||
|
| [03-category-attributes.md](./03-category-attributes.md) | 类目属性与字典值 |
|
||||||
|
| [04-product-import.md](./04-product-import.md) | 商品导入(创建/更新) |
|
||||||
|
| [05-product-info.md](./05-product-info.md) | 商品信息查询 |
|
||||||
|
| [06-product-list.md](./06-product-list.md) | 商品列表 |
|
||||||
|
| [07-import-by-sku.md](./07-import-by-sku.md) | 跟卖(按 SKU 复制) |
|
||||||
|
| [08-pictures.md](./08-pictures.md) | 图片更新 |
|
||||||
|
| [09-stocks.md](./09-stocks.md) | 库存管理 |
|
||||||
|
| [10-prices.md](./10-prices.md) | 价格更新 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速索引
|
||||||
|
|
||||||
|
### 核心流程
|
||||||
|
|
||||||
|
**1. 发布新商品**:
|
||||||
|
```
|
||||||
|
① 获取类目树 → 选择类目 → 得 description_category_id + type_id
|
||||||
|
② 获取该类目属性 → 映射属性值
|
||||||
|
③ 组装 ImportProductsV3 请求体
|
||||||
|
④ POST /v3/product/import → 得 task_id
|
||||||
|
⑤ 轮询 POST /v1/product/import/info → 得 product_id
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. 跟卖已有商品**:
|
||||||
|
```
|
||||||
|
① POST /v1/product/import-by-sku(传 sku + 基本信息)
|
||||||
|
② 轮询状态
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. 更新商品**:
|
||||||
|
- 更新商品信息:复用 `/v3/product/import`(传 `product_id` 或 `offer_id`)
|
||||||
|
- 更新图片:`POST /v1/product/pictures/import`
|
||||||
|
- 更新价格:`POST /v1/product/import/prices`
|
||||||
|
- 更新库存:`POST /v2/products/stocks`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API 基础信息
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| Base URL | `https://api-seller.ozon.ru` |
|
||||||
|
| 鉴权方式 | 请求头 `Client-Id` + `Api-Key` |
|
||||||
|
| 内容类型 | `application/json` |
|
||||||
|
| 超时建议 | 30s(常规)/ 90s(import/轮询) |
|
||||||
|
| 限流 | 官方未明确公开限流规则,建议控制在 10 req/s |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关键约束
|
||||||
|
|
||||||
|
1. **类目选择**:只有末级类目(`disabled=false`)可建品
|
||||||
|
2. **必填字段**:`name/description/category/price/尺寸重量/offer_id/images` 必填且不能为 0
|
||||||
|
3. **图片 URL**:必须是 **https 公网直链**(http 会被拒绝)
|
||||||
|
4. **属性映射**:`is_required=true` 的属性必须填写
|
||||||
|
5. **异步任务**:`/v3/product/import` 返回 `task_id`,需轮询 `/v1/product/import/info` 获取最终状态
|
||||||
|
6. **库存必须设置**:`import` 成功后商品在后台,需设置库存才能上架
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 错误码速查
|
||||||
|
|
||||||
|
| HTTP | 含义 | 处理 |
|
||||||
|
|---|---|---|
|
||||||
|
| 400 | 参数错误 | 检查请求体字段 |
|
||||||
|
| 403 | 权限不足 | 检查 Api-Key 权限级别 |
|
||||||
|
| 404 | 资源不存在 | 检查 product_id/category_id |
|
||||||
|
| 409 | 冲突(如 offer_id 重复) | 改 offer_id 或走更新 |
|
||||||
|
| 429 | 限流 | 指数退避重试 |
|
||||||
|
| 500 | 服务端错误 | 重试或联系支持 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## V2 项目集成清单
|
||||||
|
|
||||||
|
| 接口 | 用途 | 实现状态 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/v1/description-category/tree` | 类目树 | ✅ API 已建(categories.py) |
|
||||||
|
| `/v1/description-category/attribute` | 类目属性 | ✅ API 已建 |
|
||||||
|
| `/v1/description-category/attribute/values` | 属性值字典 | ✅ API 已建 |
|
||||||
|
| `/v3/product/import` | 商品导入 | ✅ API 已建(publish.py) |
|
||||||
|
| `/v1/product/import/info` | 导入状态 | ✅ API 已建 |
|
||||||
|
| `/v3/product/list` | 商品列表 | 🟡 待建 |
|
||||||
|
| `/v3/product/info/list` | 商品详情 | 🟡 待建 |
|
||||||
|
| `/v1/product/import-by-sku` | 跟卖 | 🟡 待建(二期) |
|
||||||
|
| `/v1/product/pictures/import` | 图片更新 | 🟡 待建(二期) |
|
||||||
|
| `/v2/products/stocks` | 库存 | 🟡 待建(二期) |
|
||||||
|
| `/v1/product/import/prices` | 价格 | 🟡 待建(二期) |
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# Ozon Seller API 文档整理完成
|
||||||
|
|
||||||
|
已完成 Ozon Seller API 的核心接口文档整理,涵盖商品发布、管理的完整流程。
|
||||||
|
|
||||||
|
## 已完成的文档
|
||||||
|
|
||||||
|
### 核心文档(9 个)
|
||||||
|
|
||||||
|
1. **README.md** - 总览与快速索引
|
||||||
|
2. **01-authentication.md** - 鉴权方式、Base URL、错误码、限流
|
||||||
|
3. **02-category-tree.md** - 类目树查询(选择类目)
|
||||||
|
4. **03-category-attributes.md** - 类目属性与字典值(属性映射)
|
||||||
|
5. **04-product-import.md** - 商品导入/创建/更新(核心接口)
|
||||||
|
6. **05-product-info.md** - 商品详情查询(审核状态、图片、属性)
|
||||||
|
7. **06-product-list.md** - 商品列表分页(ID 收集、批量查询)
|
||||||
|
8. **09-stocks.md** - 库存管理(必须设置才能上架)
|
||||||
|
|
||||||
|
### 待补充(二期)
|
||||||
|
|
||||||
|
- **07-import-by-sku.md** - 跟卖(按 SKU 复制 PDP)
|
||||||
|
- **08-pictures.md** - 图片更新
|
||||||
|
- **10-prices.md** - 价格批量更新
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文档特色
|
||||||
|
|
||||||
|
### 1. 完整的代码示例
|
||||||
|
- Python(服务端)示例
|
||||||
|
- TypeScript(前端)示例
|
||||||
|
- 实际可运行的代码片段
|
||||||
|
|
||||||
|
### 2. V2 项目集成指引
|
||||||
|
- 每个文档都包含"V2 项目集成"章节
|
||||||
|
- API 层实现示例(对齐 server/ 结构)
|
||||||
|
- 前端组件示例(对齐 studio/ 结构)
|
||||||
|
|
||||||
|
### 3. 最佳实践与常见问题
|
||||||
|
- 缓存策略
|
||||||
|
- 错误处理
|
||||||
|
- 性能优化
|
||||||
|
- 分页/批量操作
|
||||||
|
|
||||||
|
### 4. 实用场景
|
||||||
|
- 发布流程(端到端)
|
||||||
|
- 属性映射工作流
|
||||||
|
- 库存同步策略
|
||||||
|
- 审核状态检查
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 核心流程速查
|
||||||
|
|
||||||
|
### 完整发布流程
|
||||||
|
|
||||||
|
```
|
||||||
|
① 获取类目树 → 选择类目
|
||||||
|
POST /v1/description-category/tree
|
||||||
|
|
||||||
|
② 获取类目属性 → 映射属性
|
||||||
|
POST /v1/description-category/attribute
|
||||||
|
POST /v1/description-category/attribute/values/search
|
||||||
|
|
||||||
|
③ 组装请求体 → 发布商品
|
||||||
|
POST /v3/product/import → task_id
|
||||||
|
|
||||||
|
④ 轮询状态 → 获取 product_id
|
||||||
|
POST /v1/product/import/info → status=imported
|
||||||
|
|
||||||
|
⑤ 设置库存(必须)
|
||||||
|
POST /v2/products/stocks → 商品上架
|
||||||
|
|
||||||
|
⑥ 查询详情(可选)
|
||||||
|
POST /v3/product/info/list → 审核状态/图片/库存
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关键约束总结
|
||||||
|
|
||||||
|
1. **类目**:只有末级类目(`disabled=false`)可建品
|
||||||
|
2. **必填字段**:name/description/category/price/尺寸重量/offer_id/images
|
||||||
|
3. **图片 URL**:必须 https 公网直链
|
||||||
|
4. **属性**:`is_required=true` 的必须填写
|
||||||
|
5. **异步任务**:`/import` 返回 task_id,需轮询状态
|
||||||
|
6. **库存必须设置**:不设置库存 = 商品不可购买
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 与 V2 项目的对应关系
|
||||||
|
|
||||||
|
| Ozon API | V2 后端 API | V2 前端页面 | 状态 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/description-category/tree` | `/api/categories/tree` | CategoryPicker | ✅ 已建 |
|
||||||
|
| `/description-category/attribute` | `/api/categories/{id}/attributes` | AttributeMapper | ✅ 已建 |
|
||||||
|
| `/attribute/values` | `/api/categories/attribute/{id}/values` | - | ✅ 已建 |
|
||||||
|
| `/v3/product/import` | `/api/products/{id}/publish` | PublishPanel | ✅ 已建 |
|
||||||
|
| `/v1/product/import/info` | (background poll) | - | ✅ 已建 |
|
||||||
|
| `/v3/product/info/list` | `/api/products/{id}/ozon-detail` | OzonStatusBadge | 🟡 待建 |
|
||||||
|
| `/v3/product/list` | `/api/products/sync-from-ozon` | SyncButton | 🟡 待建 |
|
||||||
|
| `/v2/products/stocks` | `/api/products/{id}/set-stock` | StockPanel | 🟡 待建 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 使用建议
|
||||||
|
|
||||||
|
### 阅读顺序(新接入)
|
||||||
|
|
||||||
|
1. **01-authentication.md** - 了解鉴权与基础
|
||||||
|
2. **04-product-import.md** - 核心接口,先看这个
|
||||||
|
3. **02-category-tree.md** - 类目选择
|
||||||
|
4. **03-category-attributes.md** - 属性映射(难点)
|
||||||
|
5. **09-stocks.md** - 库存设置(必须)
|
||||||
|
6. 其他按需查阅
|
||||||
|
|
||||||
|
### 开发时查阅
|
||||||
|
|
||||||
|
- 看接口契约 → 查对应章节的"请求/响应"
|
||||||
|
- 看错误处理 → 查"常见错误"章节
|
||||||
|
- 看集成方式 → 查"V2 项目集成"章节
|
||||||
|
- 看最佳实践 → 查"使用场景"或"最佳实践"章节
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
### 立即可用
|
||||||
|
现有 9 个文档已覆盖 V2 项目一期的所有核心接口,可立即用于:
|
||||||
|
- 服务端 `ozon_client.py` 开发
|
||||||
|
- API 端点实现参考
|
||||||
|
- 前端组件开发参考
|
||||||
|
|
||||||
|
### 二期补充
|
||||||
|
需要时再补充:
|
||||||
|
- 跟卖(import-by-sku)
|
||||||
|
- 图片单独更新
|
||||||
|
- 价格批量更新
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [docs/v2/ozon-publish.md](../v2/ozon-publish.md) - V2 发布集成方案(与本文档配套)
|
||||||
|
- [docs/v2/api.md](../v2/api.md) - V2 后端 API 设计
|
||||||
|
- [docs/v2/database.md](../v2/database.md) - V2 数据库设计
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 商品 AI 编辑页
|
||||||
|
|
||||||
|
> 状态:已确认的产品需求与领域设计,尚未实施。它是 Atlas Seller Kit 的商品工作流规范。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
商品 AI 编辑页以一个商品(SPU)和多个可售 SKU 表达采集商品。它提供通用信息、SKU 媒体与规格编辑、SKU 定价、AI 文案、AI 套图和后续平台发布的统一入口。
|
||||||
|
|
||||||
|
页面不依赖旧项目的本地试算页;新产品应先满足多租户、云端存储和国际化架构要求。
|
||||||
|
|
||||||
|
## 已确认规则
|
||||||
|
|
||||||
|
| 主题 | 规则 |
|
||||||
|
|---|---|
|
||||||
|
| SKU | 每个商品至少有一条 SKU;单规格商品也使用默认 SKU。 |
|
||||||
|
| Ozon | 每个 SKU 对应独立 offer/import item;不依赖 Ozon 自动合并前台变体。 |
|
||||||
|
| 动态属性 | 基于当前平台商品类目 schema 动态渲染,不能把类目属性写死成列。 |
|
||||||
|
| 属性归属 | `is_aspect=true` 的平台属性默认是 SKU 属性;其他默认通用,但允许业务上改为 SKU 属性。 |
|
||||||
|
| 双规格 | 实物规格用于展示、文案和尺寸图;包装物流规格用于定价、物流和发布,二者绝不混用。 |
|
||||||
|
| 字段继承 | SKU 可继承父商品默认规格/定价参数;字段为空只表示未覆盖父默认值,不表示 SKU 不存在。 |
|
||||||
|
| 媒体 | 商品级候选素材和 SKU 已选媒体分离,以稳定 asset/SKU ID 关联,不按名称或 URL 猜测。 |
|
||||||
|
|
||||||
|
## 分册
|
||||||
|
|
||||||
|
- [页面与交互](./product-ai-editor.md)
|
||||||
|
- [数据库领域模型](./data-model.md)
|
||||||
|
- [服务端 API 契约](./api.md)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# 商品编辑 API 契约
|
||||||
|
|
||||||
|
> 接口前缀与鉴权细节待基础架构确认。所有请求由当前用户、工作空间、门店上下文授权;客户端不能跨租户指定 ID。
|
||||||
|
|
||||||
|
## 1. 采集与读取
|
||||||
|
|
||||||
|
| 接口 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `POST /api/stores/{store_id}/materials` | 上报采集商品与可选 SKU 数组;无 SKU 数组时创建默认 SKU。 |
|
||||||
|
| `GET /api/products/{product_id}/editor` | 返回编辑页所需商品、SKU、属性 schema、媒体和草稿版本。 |
|
||||||
|
| `GET /api/products/{product_id}/validation` | 返回父商品和逐 SKU 的本地发布校验。 |
|
||||||
|
|
||||||
|
采集变体按 `platform + source_item_key + source_variant_key` 幂等。只有来源明确给出 SKU—图片关联时才能绑定 SKU;其他图片进入商品候选池。未知口径的规格保留原文并标为 `ambiguous`。
|
||||||
|
|
||||||
|
## 2. 草稿、SKU 与属性
|
||||||
|
|
||||||
|
| 接口 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `PUT /api/products/{product_id}/editor-draft` | 原子保存商品、SKU、属性、排序和媒体选择;使用 ETag/版本号冲突控制。 |
|
||||||
|
| `POST /api/products/{product_id}/variants` | 创建或复制 SKU。 |
|
||||||
|
| `PATCH /api/products/{product_id}/variants/{variant_id}` | 修改 SKU;可继承字段传 `null` 恢复默认值。 |
|
||||||
|
| `DELETE /api/products/{product_id}/variants/{variant_id}` | 软删除未发布 SKU;已发布 SKU 改停用并留给发布流程处理。 |
|
||||||
|
| `GET /api/products/{product_id}/attribute-schema` | 按门店平台和当前类目返回动态属性 schema/字典。 |
|
||||||
|
|
||||||
|
schema 适配器对 Ozon 调用 `/v1/description-category/attribute` 和字典值接口,读取 `type`、`is_required`、`is_collection`、`max_value_count`、`dictionary_id`、`is_aspect`。对其他平台提供同一内部 schema 形状。
|
||||||
|
|
||||||
|
## 3. 定价、媒体和 AI
|
||||||
|
|
||||||
|
| 接口 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `POST /api/products/{product_id}/variants/{variant_id}/pricing/quote` | 用服务端有效包装规格计算预览,不落价。 |
|
||||||
|
| `POST /api/products/{product_id}/variants/{variant_id}/pricing/confirm` | 重新计算、校验、保存快照并回填售价/划线价。 |
|
||||||
|
| `GET/PUT /api/products/{product_id}/variants/{variant_id}/media` | 读取候选媒体/保存 SKU 已选媒体及顺序。 |
|
||||||
|
| `POST /api/products/{product_id}/ai/copy` | 生成父商品的多语言标题、标签、简介等候选草稿。 |
|
||||||
|
| `POST /api/products/{product_id}/ai/suite-plans` | 创建显式绑定目标 SKU 的套图规划。 |
|
||||||
|
| `POST /api/products/{product_id}/variants/{variant_id}/ai/dimension-image` | 创建单 SKU 尺寸图任务,并固化实物规格快照。 |
|
||||||
|
|
||||||
|
确认定价失败(缺失/待确认/无效包装规格或物流校验失败)返回 `422`,不改写现有价格。AI 返回候选结果,不能未经用户确认覆盖手工内容。
|
||||||
|
|
||||||
|
## 4. 后续发布
|
||||||
|
|
||||||
|
未来平台适配器接受“父商品 + 单 SKU”规范化对象。Ozon 实现将每个启用 SKU 转换为一个 `/v3/product/import.items[]`:复制通用标题、简介、类目和通用属性;填入 SKU offer、价格、包装规格、图片和变体属性。批量按平台限制分组,结果按 SKU 独立保存。
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# 商品与 SKU 数据模型
|
||||||
|
|
||||||
|
> 目标数据库:PostgreSQL。单位统一存储为毫米(`mm`)和克(`g`)。所有租户数据必须拥有或可追溯至 `workspace_id`。
|
||||||
|
|
||||||
|
## 1. 云端租户层
|
||||||
|
|
||||||
|
```text
|
||||||
|
users ── workspace_memberships ── workspaces
|
||||||
|
└─ stores ── platform_connections
|
||||||
|
```
|
||||||
|
|
||||||
|
`stores` 保存平台标识、市场/国家、默认 locale、默认币种和连接状态。敏感凭据经加密后保存在专用连接密钥记录中,绝不放进前端、日志或商品 JSON。
|
||||||
|
|
||||||
|
## 2. 商品领域
|
||||||
|
|
||||||
|
```text
|
||||||
|
stores 1 ── N products (SPU) 1 ── N product_variants (SKU)
|
||||||
|
├── product_attributes
|
||||||
|
├── product_assets ── variant_media
|
||||||
|
└── pricing_defaults ── variant_pricing_snapshots
|
||||||
|
```
|
||||||
|
|
||||||
|
每个父商品至少有一条 SKU。`products` 保存通用标题、简介、型号、条形码、采买地址、货源备注、目标市场/类目引用、富内容、默认规格及默认计价参数。可翻译内容不能只存一列,应以 `product_translations`(语言/市场、字段、值、来源、状态)或等价模型保存。
|
||||||
|
|
||||||
|
`product_variants` 保存:稳定来源键、展示名、启用/排序、SKU 货号、采购价、售价、划线价、币种、SKU 规格、包装物流规格、实物规格、发布映射和时间戳。`offer_id` 对同一门店/平台连接必须唯一。
|
||||||
|
|
||||||
|
## 3. 双规格和继承
|
||||||
|
|
||||||
|
父商品可提供默认的实物/包装规格;SKU 可以逐字段覆盖:
|
||||||
|
|
||||||
|
- 实物:`physical_length_mm`、`physical_width_mm`、`physical_height_mm`、`net_weight_g`;用于规格展示、文案和尺寸图。
|
||||||
|
- 包装物流:`package_length_mm`、`package_width_mm`、`package_height_mm`、`shipping_weight_g`;用于定价、物流校验和平台发布。
|
||||||
|
|
||||||
|
值按“SKU 覆盖 → 父商品默认”合并;两组规格独立合并。`null` 表示该字段继承,不是零,也不是 SKU 不存在。每组须有来源、原始文本、状态(`confirmed`/`ambiguous`/`missing`)和确认时间;口径未知的数据不能落入任一规格组。
|
||||||
|
|
||||||
|
## 4. 动态属性和媒体
|
||||||
|
|
||||||
|
`product_attributes` 使用结构化行保存 `scope`(common/variant)、本地 key、平台属性 ID、复杂属性 ID、JSON 值、语言、来源与确认状态。平台 schema 驱动类型、必填、字典、多值和 `is_aspect`;`is_aspect=true` 默认 variant scope,但允许业务覆盖。
|
||||||
|
|
||||||
|
`product_assets` 保存资产事实和对象存储 URL;`variant_media` 保存 SKU 对资产的图片/视频/封面选择与顺序。资产归属一律用稳定 ID;采集平台无法稳定给 SKU 归属时,只作为商品级候选池。
|
||||||
|
|
||||||
|
## 5. 定价、任务和发布
|
||||||
|
|
||||||
|
`variant_pricing_snapshots` 保存确认时的输入、合并后的包装规格、公式版本、汇率、结果、告警和时间;导出/发布时服务端重新校验,不能信任浏览器结果。所有 AI、媒体、导出和发布任务保存 `workspace_id`、`product_id` 与可空/必填的 `variant_id`,可重试、可恢复并隔离租户。
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# 商品 AI 编辑页:页面与交互
|
||||||
|
|
||||||
|
## 1. 页面定位
|
||||||
|
|
||||||
|
页面编辑一个父商品及其 SKU 集合。通用信息只维护一次;每个 SKU 是独立可售单元。单规格商品也显示一张默认 SKU 卡片,因而定价、媒体、货号与发布没有特殊分支。
|
||||||
|
|
||||||
|
```text
|
||||||
|
页头:返回 · 商品标题 · 草稿状态 · 来源
|
||||||
|
├─ 商品通用信息
|
||||||
|
│ ├─ 类目、标题、品牌、型号、主题标签、简介、JSON 富内容
|
||||||
|
│ ├─ 条形码、采买地址、货源备注
|
||||||
|
│ └─ 当前平台/市场类目的动态属性
|
||||||
|
├─ SKU 商品:卡片(默认)/ 紧凑列表
|
||||||
|
│ └─ N 个 SKU
|
||||||
|
└─ 固定操作栏
|
||||||
|
├─ 左:AI 标题/标签/描述 · AI 套图生成
|
||||||
|
└─ 右:取消并关闭 · 保存草稿 · 上架至平台(后续)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 保存和校验
|
||||||
|
|
||||||
|
页面以编辑草稿工作,不采用字段失焦即写库。保存草稿需原子提交父商品、SKU、属性和媒体顺序;存在未保存修改时关闭页面须二次确认。SKU 定价确认是例外:它会即时持久化该 SKU 的价格与定价快照。
|
||||||
|
|
||||||
|
前端根据平台商品类目 schema 渲染属性控件、必填提示、字典选择、多值限制及变体属性。`is_aspect=true` 默认显示在 SKU 区;采购业务确有需要时,用户可把非 aspect 属性(如容量)改为 SKU 属性。类目变更后,失配属性保留为未映射草稿,不能自动进入发布请求。
|
||||||
|
|
||||||
|
## 3. SKU 卡片和列表
|
||||||
|
|
||||||
|
卡片默认展示:序号、SKU 名称、启用状态、最多 3 张图片缩略图(可切换为 5)、视频封面/视频、货号、采购价、售价、划线价、定价按钮、两组规格与变体属性。列表仅是紧凑视图,字段和数据完全相同。
|
||||||
|
|
||||||
|
SKU 名称只用于展示;采集 SKU 用稳定 `source_variant_key` 关联,手工 SKU 用本地 UUID。规格字段 `null` 仅表示没有覆盖父商品默认值,绝不能据此判断是否单规格或缺少 SKU。
|
||||||
|
|
||||||
|
## 4. 定价弹窗
|
||||||
|
|
||||||
|
点击 SKU 的“定价”打开弹窗,显示该 SKU 合并后的包装物流规格,并允许调整采购价、利润率、贴单费、物流等级、划线价倍数和汇率。服务端计算预览、尺寸校验和告警;确认后保存不可变快照,并回填 SKU 销售价和划线价。
|
||||||
|
|
||||||
|
包装规格缺失、口径待确认、数值无效或不符合物流限制时不得确认。实物规格不进入物流计算;它缺失时仅禁用尺寸图。
|
||||||
|
|
||||||
|
## 5. 媒体与 AI
|
||||||
|
|
||||||
|
SKU 图片编辑弹窗左侧是该 SKU 的已选图片(排序/移除),右侧分组展示 AI 生图记录、采集主图、采集 SKU 图和采集详情图。商品级素材可被多个 SKU 选择;明确有稳定来源关联的 SKU 图只展示给对应 SKU。
|
||||||
|
|
||||||
|
AI 文案针对父商品生成候选标题、标签、简介与富内容,用户选择后写入草稿。AI 套图由左侧抽屉承载,必须显式选择目标 SKU;尺寸图一 SKU 一任务,并保存实物规格快照。
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Atlas Seller Kit 交接说明
|
||||||
|
|
||||||
|
> 更新:2026-09-01。此文是新 AI 对话、架构讨论与开发工作的首读入口。
|
||||||
|
|
||||||
|
## 1. 项目定位
|
||||||
|
|
||||||
|
这是一个全新的云端产品,不是 `ozon-seller-kit` 的本地版重构。目标用户将拥有多个门店;每个门店可属于不同平台和国家,因此语言、币种、类目和发布规则不能写死为 Ozon 或中文/俄文。
|
||||||
|
|
||||||
|
当前项目还没有业务代码、数据库、部署或依赖。不要从旧项目复制 SQLite 模型、无鉴权 API、旧 `/trial/:id` 或商品编辑页组件。可选择性复用已经验证过的纯计价公式、采集扩展思路和 AI 任务经验,但必须先适配多租户和云端边界。
|
||||||
|
|
||||||
|
## 2. 已确认产品决策
|
||||||
|
|
||||||
|
| 主题 | 决策 |
|
||||||
|
|---|---|
|
||||||
|
| 商品模型 | 每个商品是一个 SPU,且始终至少有一条 SKU;单规格商品使用默认 SKU,多规格商品使用多条 SKU。 |
|
||||||
|
| 平台可售单元 | 一个 SKU 对应一个独立平台 offer;Ozon 首个平台中对应 `/v3/product/import.items[]` 的一项和独立 `offer_id`。 |
|
||||||
|
| 通用属性 | 类目、标题、品牌、型号、主题标签、简介、JSON 富内容、条形码、采买地址、货源备注及未来商品级属性。 |
|
||||||
|
| SKU 属性 | SKU 名称、图片、视频封面、视频、货号、采购价、售价、划线价、包装重量和尺寸、商品重量和尺寸、商品颜色、颜色名称等。 |
|
||||||
|
| 型号/货号 | 型号是父商品通用字段;SKU 共用型号。货号属于 SKU,可由 AI 生成并允许人工编辑。 |
|
||||||
|
| 规格 | 实物规格与包装物流规格严格分离;尺寸图只读取实物规格,计价/物流/发布只读取包装规格。 |
|
||||||
|
| 继承 | 可继承字段的 `null` 表示该字段没有覆盖父商品默认值,不表示 SKU 不存在、名称为空或商品为单规格。 |
|
||||||
|
| 尺寸图 | 必须逐 SKU 生成,任务保存 SKU ID 和实物规格快照。 |
|
||||||
|
| 定价 | SKU 上点“定价”打开弹窗,以有效包装数据计算;用户确认后回填 SKU 销售价和划线价。 |
|
||||||
|
| 编辑体验 | 新商品 AI 编辑页取代旧商品编辑页;旧试算页不再追加多 SKU 功能。 |
|
||||||
|
| 媒体 | 采集主图/详情图是商品候选池;只有来源有稳定关联时才把图片绑定 SKU。1688、淘宝、天猫暂不假设能可靠采集 SKU 图片。 |
|
||||||
|
| 发布 | 本期只设计“上架至平台”按钮与数据边界,不实现真正发布。 |
|
||||||
|
|
||||||
|
## 3. Ozon 已核对结论
|
||||||
|
|
||||||
|
仓内旧项目的 `docs/ozon-seller-api/04-product-import.md` 记录:Ozon `/v3/product/import` 每个 item 都有独立 `offer_id`、价格、包装尺寸/重量和图片,尺寸/重量不能为零;单次最多 100 项。`03-category-attributes.md` 记录类目属性的 `is_aspect=true` 表示变体属性。
|
||||||
|
|
||||||
|
因此 Ozon 适配器应以“一 SKU 一 import item/offer”为最小稳定单位。现有资料没有可强制把多个 item 合并为一个 Ozon 前台变体组的 API 契约,不能依赖自动归并;这是未来 Ozon 联调阶段要在真实店铺或沙盒验证的能力。
|
||||||
|
|
||||||
|
## 4. 云端架构底线
|
||||||
|
|
||||||
|
1. 使用 PostgreSQL;所有业务行必须受 `workspace_id` 或可安全追溯至工作空间的外键约束。
|
||||||
|
2. 首期注册后静默创建一个个人工作空间,并只建立一个 `owner` 成员;不展示工作空间、成员、角色或店铺级权限管理。后续有团队协作需求时再扩展。
|
||||||
|
3. 平台连接属于门店:平台、国家/站点、默认语言、默认币种、加密后的凭据和能力配置均不得写进商品核心表。
|
||||||
|
4. 本地素材使用 MinIO;共享测试和生产素材使用七牛 Kodo,不使用应用服务器本地目录作为持久存储。
|
||||||
|
5. AI、生图、导出、采集转存和发布使用 Redis + BullMQ 队列/Worker,任务可重试、可恢复且携带工作空间与 SKU 上下文。
|
||||||
|
6. 用户界面语言、采集源语言、商品编辑语言和发布目标语言相互独立;文案及属性值需要支持多语言版本。
|
||||||
|
|
||||||
|
建议的核心层级:
|
||||||
|
|
||||||
|
```text
|
||||||
|
User → Workspace → Membership / Role
|
||||||
|
→ Store → Platform connection / market / locale / currency
|
||||||
|
→ Product (SPU) → ProductVariant (SKU)
|
||||||
|
→ attributes / media / pricing snapshots / tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 下一步建议
|
||||||
|
|
||||||
|
在开始前端或商品模块前,先形成以下文档并获得确认:
|
||||||
|
|
||||||
|
1. 云端多租户、认证、权限与门店模型;
|
||||||
|
2. PostgreSQL schema、迁移策略和租户隔离策略;
|
||||||
|
3. 平台适配器接口(先 Ozon,后续可接其他平台);
|
||||||
|
4. 多语言/市场/币种的数据模型;
|
||||||
|
5. 对象存储、任务队列、密钥加密和部署方案。
|
||||||
|
|
||||||
|
之后再按 `docs/product/` 的商品编辑页、数据库和 API 契约实现产品功能。
|
||||||
|
|
||||||
|
## 6. 文档导航
|
||||||
|
|
||||||
|
- [商品 AI 编辑页总览](../product/README.md)
|
||||||
|
- [编辑页交互设计](../product/product-ai-editor.md)
|
||||||
|
- [商品/SKU 数据模型](../product/data-model.md)
|
||||||
|
- [商品编辑 API 契约](../product/api.md)
|
||||||
|
- [技术栈、异步任务与部署方案](../architecture/technology-and-deployment.md)
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# 账户、门店管理与水印管理需求
|
||||||
|
|
||||||
|
> 状态:讨论中。本文只定义产品需求、页面与状态口径;不预设数据库表、接口路径或技术栈。
|
||||||
|
|
||||||
|
## 1. 本期范围与导航
|
||||||
|
|
||||||
|
用户登录后显示左侧一级菜单:在线商品、采集箱、店铺管理、水印管理、定价工具。
|
||||||
|
|
||||||
|
本轮细化账户、店铺管理和水印管理。在线商品、采集箱、定价工具只确定入口,功能在后续单独讨论。
|
||||||
|
|
||||||
|
默认产品形态是一个账户可管理多个店铺;店铺目前先接入 Ozon,但不能把页面或领域名称永久写死为 Ozon,以便后续增加其他平台。
|
||||||
|
|
||||||
|
## 2. 账户注册与登录
|
||||||
|
|
||||||
|
### 2.1 注册
|
||||||
|
|
||||||
|
注册页字段只有手机号和密码,均为必填:
|
||||||
|
|
||||||
|
| 字段 | 规则 |
|
||||||
|
|---|---|
|
||||||
|
| 手机号 | 使用国家区号 + 手机号的标准化格式保存;同一手机号只能注册一个账户。首期注册页默认中国区号,但数据模型和界面需支持后续扩展。 |
|
||||||
|
| 密码 | 由后续安全设计确定最终长度与复杂度;页面需要二次输入密码并提示规则。密码只可安全散列存储,绝不回显。 |
|
||||||
|
|
||||||
|
首期注册成功即可登录使用。短信验证和风控验证不在首期流程执行,但要预留“手机号是否已验证”和“注册/登录风控结果”的状态,未来加功能时不改变账户主流程。
|
||||||
|
|
||||||
|
### 2.2 登录与会话
|
||||||
|
|
||||||
|
用户用手机号和密码登录。登录成功进入默认工作空间与首页;若后续一个账户拥有多个工作空间,再增加切换入口。首期需要提供退出登录;忘记密码、短信登录、第三方登录、设备管理和多因素认证均不在本轮范围。
|
||||||
|
|
||||||
|
### 2.3 全局平台与当前店铺上下文
|
||||||
|
|
||||||
|
平台和当前店铺是全站工作上下文,不在每个业务页面重复选择。页头在用户信息左侧放置“平台选择器”;用户切换平台后,菜单、类目、物流商、佣金规则和页面数据均切换到该平台范围。
|
||||||
|
|
||||||
|
在平台下有多个已授权店铺时,系统同时使用该平台的默认店铺作为“当前店铺”;后续可在同一页头区域增加店铺切换。新用户进入一个平台时优先选中该平台默认店铺。定价方案、商品和上架任务均从此全局上下文读取平台与店铺,避免表单中出现相互矛盾的平台选择。
|
||||||
|
|
||||||
|
## 3. 店铺管理
|
||||||
|
|
||||||
|
### 3.1 列表页面
|
||||||
|
|
||||||
|
入口为“店铺管理”。页面左上方有“添加店铺授权”按钮;列表按添加时间倒序,支持空状态。字段如下:
|
||||||
|
|
||||||
|
| 列 | 展示/来源 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 序号 | 当前列表序号 | 仅是 UI 序号,不作为店铺标识。 |
|
||||||
|
| 店铺名称 | 用户填写 | 允许编辑,不能为空。 |
|
||||||
|
| 店铺 Client ID | 用户填写 | Ozon `Client-Id`;可完整显示,但不应把 API Key 放在列表或详情中。 |
|
||||||
|
| 密钥过期时间 | 平台可提供时同步 | 现有 Ozon Seller API 资料仅说明 `Api-Key` 由卖家后台生成、只显示一次,未发现可读取密钥过期时间的接口。因此首期展示 `—`;同时建议在详情/编辑页显示“最近验证时间”和“凭据验证状态”。不能用本系统添加时间推算到期。 |
|
||||||
|
| 商品限额 | 平台可提供时同步 | 指一家门店可铺货的商品数量上限。现有资料未确认 Ozon 可返回该配额;首期显示 `—`,不得把当前商品数量误作限额。 |
|
||||||
|
| 货币类型 | 用户选择 | 首期提供人民币 `CNY` 和俄罗斯卢布 `RUB`;其他币种由后台配置后才显示。一个店铺只有一个默认结算/计价币种。 |
|
||||||
|
| 默认店铺 | 系统状态 | 每个工作空间最多一个默认店铺;设为默认会自动取消原默认店铺。 |
|
||||||
|
| 仓库数量 | 平台同步 | Ozon 可通过 `POST /v1/warehouse/list` 获取仓库数组,列表显示最近一次成功同步的数量;未同步、凭据失效或请求失败时显示 `—` 并保留最近同步时间。 |
|
||||||
|
| Cookie 状态 | 预留字段 | Ozon 商品发布使用 `Client-Id + Api-Key` 的 Seller API 鉴权,不需要浏览器 Cookie。因此首期此列保持空白;只有未来接入网页自动化等 Seller API 覆盖不到的能力,才增加 Cookie 授权、加密保存、失效检测与更新入口。 |
|
||||||
|
| 状态 | 系统状态 | `验证中`、`正常`、`凭据失效`、`同步异常`、`已停用`。添加/更新凭据后先验证再置为正常。 |
|
||||||
|
| 绑定水印 | 关联状态 | 显示已绑定的水印模板名称,未绑定时显示“未绑定”。 |
|
||||||
|
| 添加时间 | 系统时间 | 显示店铺创建时间。 |
|
||||||
|
| 操作 | 用户操作 | `绑定水印`、`编辑`、`删除`。删除必须二次确认,首期建议软删除:不再出现在默认列表,但保留关联商品和操作审计。默认店铺删除前必须先指定另一店铺为默认,或在没有其他店铺时允许默认店铺为空。 |
|
||||||
|
|
||||||
|
“仓库数量”的来源已经在现有 Ozon 资料中确认。密钥过期时间和商品限额如果无法由平台获得,统一展示 `—`,不推算、不猜测;Cookie 不是商品发布所需的 Seller API 凭据,首期保持空白。
|
||||||
|
|
||||||
|
### 3.2 添加店铺授权
|
||||||
|
|
||||||
|
点击“添加店铺授权”弹出表单,字段顺序固定如下:
|
||||||
|
|
||||||
|
| 字段 | 必填 | 规则 |
|
||||||
|
|---|---:|---|
|
||||||
|
| 店铺名称 | 是 | 用户自定义名称。 |
|
||||||
|
| Client ID | 是 | Ozon `Client-Id`。 |
|
||||||
|
| API 密钥 | 是 | 仅提交一次给服务端验证和加密保存;保存后永不在 UI 或接口响应中返回明文。 |
|
||||||
|
| 货币类型 | 是 | 首期仅 `CNY`、`RUB`;由可配置币种清单驱动,默认建议 `RUB`。 |
|
||||||
|
| 是否默认店铺 | 否 | 新账户的首家店铺强制成为默认;其他店铺由勾选决定。 |
|
||||||
|
| 备注 | 否 | 仅供账户内管理,不对平台发送。 |
|
||||||
|
|
||||||
|
提交后系统必须用该凭据调用一个低风险 Ozon API 进行校验,并拉取仓库列表;验证失败时不得把店铺标为正常,错误提示不能包含 API Key。首期先按 Ozon `Client-Id + Api-Key` 接入,未来其他平台在同一“添加店铺授权”入口内按平台显示各自表单。
|
||||||
|
|
||||||
|
### 3.3 绑定水印
|
||||||
|
|
||||||
|
点击列表操作“绑定水印”弹出对话框:
|
||||||
|
|
||||||
|
- 店铺名称:只读,不可修改。
|
||||||
|
- 水印:下拉列表,包含当前账户可用的水印模板,也可以选择“暂不绑定”。
|
||||||
|
- 确认后,该店铺后续上架商品时默认使用此模板处理商品图片;已有图片不会被自动重写。
|
||||||
|
|
||||||
|
一个店铺首期只可绑定一个默认水印;同一水印模板可以绑定给多个店铺。
|
||||||
|
|
||||||
|
### 3.4 编辑与删除
|
||||||
|
|
||||||
|
编辑复用添加表单,但 API Key 默认不显示;用户只有重新输入新 Key 时才更新凭据并触发验证。删除只处理本系统的店铺连接,不会删除 Ozon 平台店铺、商品或仓库。
|
||||||
|
|
||||||
|
## 4. 水印管理
|
||||||
|
|
||||||
|
### 4.1 列表页面
|
||||||
|
|
||||||
|
页面左上方有“添加水印模板”按钮。列表按创建时间倒序,列为:序号、水印名称、水印类型、水印内容、水印位置、创建时间、操作。
|
||||||
|
|
||||||
|
| 列 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 水印名称 | 模板名称,在当前账户内唯一。 |
|
||||||
|
| 水印类型 | `文字水印` 或 `图片水印`。 |
|
||||||
|
| 水印内容 | 文字水印展示文字内容;图片水印展示缩略图/文件名,不显示原始存储路径。 |
|
||||||
|
| 水印位置 | 九宫格位置,以清晰中文名展示,如“右下”。 |
|
||||||
|
| 操作 | `查看效果`、`编辑`、`删除`。 |
|
||||||
|
|
||||||
|
删除已被店铺绑定的模板时,必须二次确认并说明影响;确认删除后应解除所有店铺绑定,而不是让任务继续引用一个已删除的模板。已生成的历史图片不受影响。
|
||||||
|
|
||||||
|
### 4.2 添加/编辑水印模板
|
||||||
|
|
||||||
|
点击“添加水印模板”打开弹窗。先选择水印类型,表单随类型切换;编辑时保留原类型和参数,并支持更换图片或文字。模板名称与水印类型始终必填。
|
||||||
|
|
||||||
|
共同字段:
|
||||||
|
|
||||||
|
| 字段 | 规则 |
|
||||||
|
|---|---|
|
||||||
|
| 模板名称 | 必填;当前账户内唯一。 |
|
||||||
|
| 水印类型 | 必填,文字或图片。切换类型时需确认,因为原类型专属字段会被清空。 |
|
||||||
|
| 不透明度 | 使用 Ant Design Slider;取值 0%–100%,预览同步更新。 |
|
||||||
|
| 水印位置 | 九宫格单选:左上、上中、右上、左中、居中、右中、左下、下中、右下。 |
|
||||||
|
| 预览效果 | 基于固定演示底图实时渲染位置、缩放和透明度;首期仅预览,不写入商品图。 |
|
||||||
|
|
||||||
|
图片水印专属字段:
|
||||||
|
|
||||||
|
| 字段 | 必填 | 规则 |
|
||||||
|
|---|---:|---|
|
||||||
|
| 上传图片 | 是 | 上传 PNG/JPG/WebP;透明底 PNG 是推荐格式。具体大小、像素与文件大小限制由媒体模块确定。 |
|
||||||
|
| 图片缩放 | 是 | 使用 Ant Design Slider;首期建议取值 10%–200%,表示相对演示底图短边的比例。 |
|
||||||
|
|
||||||
|
文字水印专属字段:
|
||||||
|
|
||||||
|
| 字段 | 必填 | 规则 |
|
||||||
|
|---|---:|---|
|
||||||
|
| 文字内容 | 是 | 允许的最大长度与禁用字符由媒体模块确定。 |
|
||||||
|
| 字体 | 是 | 由服务端返回的、已随渲染服务部署的字体清单驱动,不能直接读取浏览器或开发机字体。 |
|
||||||
|
| 字体大小 | 是 | 12px–60px。 |
|
||||||
|
|
||||||
|
“查看效果”使用模板当前配置和固定演示底图打开预览。
|
||||||
|
|
||||||
|
### 4.3 上架时的默认加水印规则
|
||||||
|
|
||||||
|
当用户选择的目标店铺已经绑定水印模板时,点击“上架至 Ozon”后,系统在上传每张商品图片前生成一份带水印的派生图,并把派生图上传给 Ozon;未绑定模板则上传原选图。规则如下:
|
||||||
|
|
||||||
|
- 商品级图片与每个 SKU 已选图片都适用,最终以实际要提交给 Ozon 的图片集合为准。
|
||||||
|
- 原始图片和编辑页已选图片不被改写;水印处理产物是一次独立的、可追溯的上传副本。
|
||||||
|
- 上传任务固化水印模板版本、位置、缩放/字体和不透明度;模板后续编辑不影响已上架商品。
|
||||||
|
- 用户在具体上架任务中应能查看“将应用的水印模板”;是否允许该次上架临时取消或更换模板,留待发布流程需求确认。
|
||||||
|
|
||||||
|
## 5. 本轮待确认事项
|
||||||
|
|
||||||
|
1. 商品限额的 Ozon 数据来源与刷新频率仍待平台接口/真实店铺联调确认;在此之前固定显示 `—`。
|
||||||
|
2. 账户是否从首期就允许多人共享同一工作空间/协作店铺?如果不做,首期仍按“一个账户一个工作空间”使用,但架构会保留扩展空间。
|
||||||
|
3. 上架任务是否允许临时取消或替换门店默认水印?
|
||||||
|
|
||||||
|
## 6. 非功能性约束
|
||||||
|
|
||||||
|
- API Key、Cookie、密码都属于高敏感信息:禁止日志记录、客户端缓存、列表返回和明文回显。
|
||||||
|
- 所有店铺、水印、素材与后续商品数据必须按账户/工作空间隔离。
|
||||||
|
- 凭据验证、仓库同步、图片上传和水印渲染可以失败或耗时,页面应展示最后一次状态与可重试入口,不能长时间阻塞表单提交。
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# CEL Ozon-rFBS 资费规则(待审核)
|
||||||
|
|
||||||
|
> 状态:规则已完成首轮业务 Review,仍须用 CEL 实际账单复算后才能作为生产报价规则启用。
|
||||||
|
>
|
||||||
|
> 来源:`CEL最新资费试算表V6.17.xlsx` 的 `OZON-rFBS` 工作表;读取时间:2026-09-01。本文按工作表展示内容与单元格公式转写,并参考旧项目 `web/js/app.js` 的试算逻辑核对单位与计算表达。原 Excel 未被修改。
|
||||||
|
|
||||||
|
## 1. 范围和统一口径
|
||||||
|
|
||||||
|
- 首期服务商:`CEL`。
|
||||||
|
- 首期模式:`Ozon-rFBS`。
|
||||||
|
- 本文所有运费、商品货值阈值均为 **人民币 CNY**;重量为 **kg**;尺寸为 **cm**;体积重同样为 kg。
|
||||||
|
- 工作表的 Q9 输入格标注为“填入销售价格/人民币(元)”,下文称“商品货值”。商品货值固定以人民币(CNY)计价:它不是采购成本,也不是包裹申报价值;它用于判断商品适用哪个 CEL 产品类型:例如 `≤135 CNY` 适用 Extra Small/Budget,`135 < 货值 ≤635 CNY` 适用 Small/Big,`635 < 货值 ≤22,525 CNY` 适用 Premium Small/Premium Big。系统报价时以最终建议销售价按本次汇率折算得到的人民币金额作为商品货值。
|
||||||
|
- 商品货值会与佣金档、物流产品类型相互影响:不同 CEL 产品类型的固定费、每 kg 单价、尺寸限制和是否计抛不同;产品类型改变会改变物流费,进而改变建议售价和商品货值。因此必须在同一次报价中求得稳定结果,不能先固定一个物流产品类型。
|
||||||
|
- 所有 CEL 费率均是“每票固定费 + 按计费重量的单价”。例如 `46.80 CNY/kg + 3.12 CNY/票` 等价于源表的 `0.0468 CNY/g + 3.12 CNY/票`。
|
||||||
|
- “不计抛”表示只使用实际重量;“计抛”表示按规则计算体积重并与实际重量取大。源表不要求所有渠道用同一体积重公式。
|
||||||
|
|
||||||
|
## 2. 从源表得到的 CEL 产品类型
|
||||||
|
|
||||||
|
### 2.1 低货值:货值 ≤ 135 CNY
|
||||||
|
|
||||||
|
| 产品类型 | 运输方式 | 源表渠道名 | 时效 | 实际重量 | 尺寸/计抛 | 运费公式(CNY) |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| Extra Small(超级轻小件) | 陆空特快 | Express Extra Small | 5–10 天 | `0 < W ≤ 0.5` | 三边和 `≤90`,最长边 `≤60`,不计抛 | `46.80 × W + 3.12` |
|
||||||
|
| Extra Small(超级轻小件) | 陆空标准 | Standard Extra Small | 10–15 天 | `0 < W ≤ 0.5` | 同上 | `36.40 × W + 3.12` |
|
||||||
|
| Extra Small(超级轻小件) | 陆运经济 | Economy Extra Small | 15–25 天 | `0 < W ≤ 0.5` | 同上 | `26.00 × W + 3.12` |
|
||||||
|
| Budget(低客单标准件) | 陆空特快 | Express Budget | 5–10 天 | `0.5 < W ≤ 30` | 三边和 `≤150`,最长边 `≤60`,不计抛 | `34.32 × W + 23.92` |
|
||||||
|
| Budget(低客单标准件) | 陆空标准 | Standard Budget | 10–15 天 | `0.5 < W ≤ 30` | 同上 | `26.00 × W + 23.92` |
|
||||||
|
| Budget(低客单标准件) | 陆运经济 | Economy Budget | 15–25 天 | `0.5 < W ≤ 30` | 同上 | `17.68 × W + 23.92` |
|
||||||
|
|
||||||
|
源表在 Budget 行显示“密度 kg/m³”,公式为 `实际重量 ÷ (长×宽×高) × 1,000,000`,但**该密度没有参与任何可见的运价或有效性公式**;新系统只记录为参考指标,不能擅自增加密度限制。
|
||||||
|
|
||||||
|
### 2.2 中货值:135 < 货值 ≤ 635 CNY
|
||||||
|
|
||||||
|
| 产品类型 | 运输方式 | 源表渠道名 | 时效 | 实际重量 | 尺寸/计抛 | 运费公式(CNY) |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| Small(轻小件) | 陆空特快 | Express Small | 5–10 天 | `0.001 ≤ W ≤ 2`;最小计费重 `0.001` | 三边和 `≤150`,最长边 `≤60`,不计抛 | `46.80 × W + 16.64` |
|
||||||
|
| Small(轻小件) | 陆空标准 | Standard Small | 10–15 天 | `0.001 ≤ W ≤ 2`;最小计费重 `0.001` | 同上 | `36.40 × W + 16.64` |
|
||||||
|
| Small(轻小件) | 陆运经济 | Economy Small | 15–25 天 | `0.001 ≤ W ≤ 2`;最小计费重 `0.001` | 同上 | `26.00 × W + 16.64` |
|
||||||
|
| Big(大件) | 陆空标准 | Standard Big | 10–15 天 | `2 < W ≤ 30` | 三边和 `≤310`,最长边 `≤150`;计抛,体积重 `V=L×W×H÷12000`;`ceil(V) ≤31` | `26.00 × CW + 37.44` |
|
||||||
|
| Big(大件) | 陆运经济 | Economy Big | 15–25 天 | `2 < W ≤ 30` | 同上 | `17.68 × CW + 37.44` |
|
||||||
|
|
||||||
|
其中 Big 的计费重 `CW = max(W, roundup(V, 3))`,即体积重先向上取到 3 位小数,再与实际重量取大;有效性判断使用 `ceil(V) ≤31`。注意此处最大限制是实际重量 30 kg、体积重向上取整后 31 kg,不是笼统的“计费重 ≤30 kg”。
|
||||||
|
|
||||||
|
### 2.3 高货值:635 < 货值 ≤ 22,525 CNY
|
||||||
|
|
||||||
|
| 产品类型 | 运输方式 | 源表渠道名 | 时效 | 实际重量 | 尺寸/计抛 | 运费公式(CNY) |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| Premium Small(高客单小件) | 陆空特快 | Express Premium Small | 5–10 天 | `0.001 ≤ W ≤ 5`;最小计费重 `0.001` | 三边和 `≤250`,最长边 `≤150`,不计抛 | `46.80 × W + 22.88` |
|
||||||
|
| Premium Small(高客单小件) | 陆空标准 | Standard Premium Small | 10–15 天 | `0.001 ≤ W ≤ 5`;最小计费重 `0.001` | 同上 | `36.40 × W + 22.88` |
|
||||||
|
| Premium Small(高客单小件) | 陆运经济 | Economy Premium Small | 15–25 天 | `0.001 ≤ W ≤ 5`;最小计费重 `0.001` | 同上 | `26.00 × W + 22.88` |
|
||||||
|
| Premium Big(高客单大件) | 陆空标准 | Standard Premium Big | 10–15 天 | `5 < W ≤30` | 三边和 `≤310`,最长边 `≤150`,次长边 `≤80`;计抛,`V=L×W×H÷12000`;`ceil(V) ≤80` | `29.12 × CW + 64.48` |
|
||||||
|
| Premium Big(高客单大件) | 陆运经济 | Economy Premium Big | 15–25 天 | `5 < W ≤30` | 同上 | `23.92 × CW + 64.48` |
|
||||||
|
|
||||||
|
Premium Big 的计费重同样为 `CW = max(W, roundup(V, 3))`。源表允许计费重因体积重高于实际重量,但体积重向上取整后不得超过 80 kg。
|
||||||
|
|
||||||
|
### 2.4 香港空运:不纳入一期
|
||||||
|
|
||||||
|
源表 `OZON-rFBS` Tab 包含 `CEL Express HK`(香港空运)渠道,但已确认**不纳入一期,也不在首期定价工具中展示或参与报价**。此规则不录入首期 CEL 规则集;如未来启用,需重新审核其货值、体积重除数和 0.1 kg 进位方式。
|
||||||
|
|
||||||
|
### 2.5 源表默认样例复核
|
||||||
|
|
||||||
|
工作表默认输入为:货值 `100 CNY`、实际重量 `0.99 kg`、尺寸 `30×31×11 cm`。它符合 Budget 的货值、重量和尺寸限制,源表缓存结果为:
|
||||||
|
|
||||||
|
| 渠道 | 源表结果 | 复算 |
|
||||||
|
|---|---:|---:|
|
||||||
|
| Express Budget | `57.8968 CNY` | `0.99 × 34.32 + 23.92 = 57.8968` |
|
||||||
|
| Standard Budget | `49.66 CNY` | `0.99 × 26 + 23.92 = 49.66` |
|
||||||
|
| Economy Budget | `41.4232 CNY` | `0.99 × 17.68 + 23.92 = 41.4232` |
|
||||||
|
|
||||||
|
上述三项与单元格公式、缓存值一致。其余首期常规产品类型因货值或重量范围不符而为空;HK 虽有 `115 CNY` 的源表结果,但因不纳入一期而不进入新系统报价。
|
||||||
|
|
||||||
|
## 3. 返回服务与服务方式
|
||||||
|
|
||||||
|
所有源表渠道都标注支持“PUDO 到取货点 / Courier 到门”。工作表附注建议优先使用到取货点。
|
||||||
|
|
||||||
|
| 产品类型 | 源表退货服务 |
|
||||||
|
|---|---|
|
||||||
|
| Extra Small、Budget | 免费销毁;不支持改派;不支持退回。 |
|
||||||
|
| Small、Big、Premium Small、Premium Big | 免费销毁;支持改派;支持退回,退回收费为正向运费 × 1.5。 |
|
||||||
|
|
||||||
|
上述退货信息暂作为物流服务元数据展示,不进入首期单件售价公式;若日后将预期退货成本计入“其他费”,必须建立单独、可配置的预期损失规则。
|
||||||
|
|
||||||
|
## 4. 系统报价时的计算顺序
|
||||||
|
|
||||||
|
1. 从全局当前平台/店铺读取 `Ozon-rFBS`、CEL 和默认定价方案;读取 SKU 的采购成本、有效包装重量及长宽高。
|
||||||
|
2. 根据商品类目映射获取佣金类目,逐佣金档取候选佣金率。
|
||||||
|
3. 对每个 CEL 产品类型/运输方式,按货值、重量、尺寸和计抛规则验证可用性,并以其运费公式计算跨境物流费。
|
||||||
|
4. 因为销售价 CNY 等值同时决定**佣金档位**和**CEL 产品类型**,引擎必须枚举“佣金档 × CEL 渠道”的候选组合,依据完整定价公式求售价,再回验:
|
||||||
|
- 销售价命中该佣金档;
|
||||||
|
- 销售价折算的 CNY 货值命中该 CEL 产品类型;
|
||||||
|
- 重量、尺寸、体积重与货值均合规。
|
||||||
|
5. 只有全部回验通过的候选结果才展示。结果卡显示 CEL 产品类型、运输方式、计费重、是否计抛、物流费、佣金档位、各规则版本与完整成本拆分。
|
||||||
|
6. 在多个有效候选中,按用户选定的默认运输方式优先;即时试算同时横向展示所有有效方式。可额外标记“物流费最低”,但不得将其默认等同于最推荐方式或自动替代用户方案。
|
||||||
|
|
||||||
|
## 5. 与旧项目 web 试算逻辑的关系
|
||||||
|
|
||||||
|
旧项目 `web/js/app.js` 的 `low/high/high2` 运费常量只覆盖了部分 CEL 数字:例如 Extra Small 的 `46.80/36.40/26.00 +3.12`、Budget 的 `34.32/26.00/17.68 +23.92`、Small 的 `46.80/36.40/26.00 +16.64`。但旧逻辑:
|
||||||
|
|
||||||
|
- 不区分 CEL 的货值产品类型;
|
||||||
|
- 未实现 Big/Premium Big 的体积重除数和计费重;
|
||||||
|
- 采用固定 12% / 18% 佣金及固定总抽成,不能用于新项目的类目阶梯佣金;
|
||||||
|
- 将“贴单费”拼入物流费,新项目应按需求将“跨境物流费”与“国内运费 + 代贴单”分别显示和保存。
|
||||||
|
|
||||||
|
因此旧项目仅用来辅助理解源表单位和部分历史费率,不能直接复用为 Atlas 的 CEL 报价实现。
|
||||||
|
|
||||||
|
## 6. 需要你 Review 的源表疑点
|
||||||
|
|
||||||
|
以下均为源表“展示文字”和“单元格公式”不完全一致之处。本文保留来源差异,并在需要形成可读规则时采用了较保守的暂定表达;正式启用前必须由你确认:
|
||||||
|
|
||||||
|
1. **Premium Small 陆空特快单价**:已确认首期资费为 `0.0468 CNY/g + 22.88 CNY/票`,规范化记录为 `46.80 CNY/kg + 22.88 CNY/票`。源表资费文字为 `0.046.88 元/克 + 22.88 元/票`,其单元格公式则写作 `46.88 × W + 22.88`;两者均与已确认规则不一致,作为来源差异保留,导入时不得使用源表的 `46.88`。
|
||||||
|
2. **Small / Premium Small 的最小重量**:已确认最小实际重量和最小计费重均严格为 `0.001 kg`。系统以 kg 存储并支持 3 位小数输入;不计抛渠道的计费重直接使用有效实际重量,不能低于 `0.001 kg`。HK 不在一期范围。
|
||||||
|
3. **Big 和 Premium Big 的计费重精度**:已确认暂按源表规则执行:体积重按 `roundup(V, 3)` 向上取 3 位小数后,与实际重量取大;资格上限仍用 `ceil(V)` 判断。
|
||||||
|
4. **香港空运**:已确认不纳入一期,不建立首期规则、不展示也不参与报价。
|
||||||
|
|
||||||
|
## 7. 启用前检查清单
|
||||||
|
|
||||||
|
- [x] 完成上述四个源表疑点的首轮业务确认。
|
||||||
|
- [x] 已确认“商品货值”等于 Ozon 最终销售价按当次汇率折算后的人民币(CNY)金额,并用于匹配 CEL 产品类型。
|
||||||
|
- [ ] 确认各产品类型的“商品货值”是否等于 Ozon 最终销售价的 CNY 折算值。
|
||||||
|
- [ ] 为每条 CEL 规则补充生效日期、版本号和录入/审核人。
|
||||||
|
- [ ] 与 CEL 用一组真实包裹数据(至少各覆盖 Extra Small、Budget、Small、Big、Premium Small、Premium Big)复算并对比报价。
|
||||||
|
- [ ] 确认佣金档位与 CEL 货值类型同时跨档时的最终报价和边界行为。
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
# 定价工具需求
|
||||||
|
|
||||||
|
> 状态:讨论中。本文件定义产品交互、输入输出和规则边界;不在本阶段确定数据库、接口或具体费用数值。
|
||||||
|
|
||||||
|
## 1. 定位
|
||||||
|
|
||||||
|
“定价工具”是同一个定价引擎的两个入口,不应建设两套公式:
|
||||||
|
|
||||||
|
1. **即时试算**:不依赖已有商品,用户输入类目、成本和包裹数据,比较同一跨境物流商下不同运输方式的报价、成本与利润。
|
||||||
|
2. **定价方案**:面向门店保存可复用的扣费与物流选择。商品编辑页的 SKU 引用方案后,只需补充采购成本、包裹重量和尺寸,即可计算销售价和划线价。
|
||||||
|
|
||||||
|
两种入口均调用同一版本的费用规则和计算引擎;每一次确认用于 SKU 的结果都必须保存完整输入、规则版本、汇率和费用拆分,不能只保存一个最终价格。
|
||||||
|
|
||||||
|
## 2. Ozon 类目佣金结论
|
||||||
|
|
||||||
|
现有项目中的 Ozon Seller API 文档包含类目树、类目属性、商品导入、商品查询和仓库接口;未包含“按类目获取佣金”的接口。商品导入也不返回可用于报价的类目佣金。
|
||||||
|
|
||||||
|
因此第一期在**当前平台内维护独立的佣金类目树与平台佣金规则表**:用户选择平台商品类目后,系统通过同平台映射找到对应的平台佣金类目,再按已维护且有生效日期的阶梯佣金规则取值。每条结果必须显示佣金来源和版本:
|
||||||
|
|
||||||
|
- `manual_rule`:运营维护的类目佣金规则,首期唯一可靠来源;
|
||||||
|
- `competitor_capture`:从友商页面抓取的待审核规则来源;审核通过并发布版本后,才可作为正式报价规则;
|
||||||
|
- `platform_sync`:未来若 Ozon 提供稳定接口后同步;
|
||||||
|
- `product_observed`:未来从已上架商品的实际费用回填,仅作参考,不能覆盖正式规则。
|
||||||
|
|
||||||
|
没有覆盖该类目的规则时,系统不得自动套用相邻类目或旧比例:显示“缺少佣金规则”,允许在即时试算中临时填写佣金率并清楚标记为临时值;SKU 定价确认则需要人工补齐规则或明确使用一次性覆盖值。
|
||||||
|
|
||||||
|
### 2.1 已获得的佣金数据
|
||||||
|
|
||||||
|
已获得一份从友商页面抓取的 Ozon 佣金数据。其结构是“佣金一级类目 → 佣金二级类目 → 销售价区间/佣金比例”,例如:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"label": "服装",
|
||||||
|
"cate_id": 15621031,
|
||||||
|
"children": [{
|
||||||
|
"label": "配饰",
|
||||||
|
"cate_id": 41777465,
|
||||||
|
"children": [
|
||||||
|
{ "label": "售价 ≤ 1500₽ (12.00%)", "value": "1,12.00" },
|
||||||
|
{ "label": "1500₽ < 售价 ≤ 5000₽ (14.00%)", "value": "2,14.00" },
|
||||||
|
{ "label": "售价 > 5000₽ (20.50%)", "value": "3,20.50" }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
约定如下:
|
||||||
|
|
||||||
|
- `label` 是展示名称;`cate_id`、层级 `id` 和叶子 `value` 是抓取来源字段,保留为来源标识,不能假设它们等同于 Ozon 商品类目 API 的 ID。
|
||||||
|
- 叶子 `value` 的格式目前为 `档位序号,佣金百分比`;实际价格下界/上界以 `label` 解析或人工录入为准,导入时要转换为结构化的最小值、最大值、边界包含方式和佣金率,不能长期依赖字符串解析计算。
|
||||||
|
- 该数据源标记为 `competitor_capture`,不是 Ozon 官方实时接口。导入时必须记录抓取日期、适用市场/履约模式、审核状态和生效版本;未经业务确认的版本只能用于参考试算,不能默认为正式发布报价依据。
|
||||||
|
|
||||||
|
### 2.2 平台商品类目与平台佣金类目的映射
|
||||||
|
|
||||||
|
平台商品类目与平台佣金类目是同一平台内的两套独立体系;它们都必须归属一个平台,禁止以相同名称或 ID 自动等同,也不能跨平台复用。一个平台佣金类目可对应多个平台商品类目;一个平台商品类目也可能因履约模式、市场或规则变更而在不同时期映射到不同的平台佣金类目。
|
||||||
|
|
||||||
|
维护“商品类目 → 佣金类目”的显式映射,并为映射保存平台、履约模式、适用市场、生效区间、优先级、来源和审核状态。报价时只取当前平台、市场、履约模式和日期下唯一有效的映射;没有唯一映射就要求人工选择,不作猜测。
|
||||||
|
|
||||||
|
### 2.3 阶梯佣金与反算售价
|
||||||
|
|
||||||
|
佣金率会随最终销售价变化,而销售价又受佣金影响。因此不能先随意选一档佣金后只算一次。报价引擎必须逐档计算候选销售价,并验证候选价是否落入该档价格区间;满足区间的候选即为有效结果。例如先按“≤1500₽、12%”求价,若结果超过 1500₽,则该档无效,继续按下一档重算。
|
||||||
|
|
||||||
|
结果卡必须展示命中的佣金类目、价格档位、佣金率、规则版本和边界条件。若某一档的计算结果恰好在边界,按规则中保存的包含/排除定义处理,而不是使用前端浮点数比较。
|
||||||
|
|
||||||
|
## 3. 页面信息架构
|
||||||
|
|
||||||
|
点击左侧“定价工具”进入一个页面,顶部使用三个页签:
|
||||||
|
|
||||||
|
| 页签 | 用途 | 首期 |
|
||||||
|
|---|---|---|
|
||||||
|
| 即时试算 | 临时输入并横向比较运输方式 | 是 |
|
||||||
|
| 定价方案 | 管理门店可复用的默认扣费和物流偏好 | 是 |
|
||||||
|
| 试算记录 | 查看、复制和复算已保存的结果 | 后续,可先只保留 SKU 的计算快照 |
|
||||||
|
|
||||||
|
“物流服务商的价格表”和“类目佣金规则”属于后台可配置资料,不应让普通卖家在每次试算中编辑。第一期仅内置 `CEL`,但所有选择器、规则和结果都以“服务商 + 运输方式 + 规则版本”表达,后续可增加其他服务商而不用改商品或 SKU 结构。
|
||||||
|
|
||||||
|
## 4. 即时试算
|
||||||
|
|
||||||
|
### 4.1 输入区
|
||||||
|
|
||||||
|
页面采用左侧输入、右侧结果的布局。目标平台与当前店铺不在此重复选择:它们来自页头全局工作上下文。平台是必选上下文;不同平台拥有独立的类目、佣金规则、跨境物流商和运输方式。输入分为以下四组:
|
||||||
|
|
||||||
|
| 分组 | 字段 | 规则 |
|
||||||
|
|---|---|---|
|
||||||
|
| 目标市场 | 履约模式、当前平台商品类目 | 当前平台和当前店铺从页头读取;店铺自动带入币种和默认定价方案。平台商品类目和平台佣金类目均强绑定当前平台;履约模式首期预留 FBS/FBO,实际可用项由该平台的佣金规则决定。 |
|
||||||
|
| 商品与包裹 | 采购成本、包裹实重、长/宽/高 | 采购成本首期以 CNY 输入;重量以 g、尺寸以 cm 或 mm 输入。体积由三边自动计算,不能只存一个孤立体积值。允许显示并在必要时人工确认体积重。 |
|
||||||
|
| 定价目标 | 期望加价率、划线折扣率、广告费占比、其他占比 | 所有比例必须明确单位为 `%`。期望加价率与划线折扣率的计算口径见第 7 节。 |
|
||||||
|
| 费用与物流 | 跨境物流商、运输方式、国内运费+代贴单、尾程(Ozon 代理费)、提现费、汇率 | 默认选择 CEL 并同时勾选其可用运输方式;用户可比较全部方式。国内运费+代贴单为固定金额;尾程、提现费为比例;汇率必须显示来源、值和更新时间,允许本次覆盖。 |
|
||||||
|
|
||||||
|
“其他”可先作为单一百分比输入,展示名称为“其他(提醒、货损等)”。规则模型必须支持将来拆成多项费用,例如货损、退款准备金、保险、税费,而不改变最终报价结构。
|
||||||
|
|
||||||
|
### 4.2 结果区
|
||||||
|
|
||||||
|
每种被选择的运输方式生成一张可比较的结果卡(桌面端可横向表格、移动端可纵向卡片)。默认按建议销售价升序,并突出推荐方式;推荐只依据完整成本、目标利润和规则有效性,不代表物流时效承诺。
|
||||||
|
|
||||||
|
每张结果必须同时展示:
|
||||||
|
|
||||||
|
- 建议销售价和划线价(目标币种);
|
||||||
|
- 毛利金额、毛利率、目标利润达成情况;
|
||||||
|
- 采购成本;
|
||||||
|
- 平台佣金(比例、金额、规则来源和版本);
|
||||||
|
- 跨境物流费(实重、不计抛)以及实际计费重、抛重差额和最终跨境物流费;
|
||||||
|
- 国内运费 + 代贴单;
|
||||||
|
- 广告费;
|
||||||
|
- 尾程(Ozon 代理费);
|
||||||
|
- 提现费;
|
||||||
|
- 其他(货损等);
|
||||||
|
- 总成本、扣费总额、汇率与生效规则版本。
|
||||||
|
|
||||||
|
利润和建议价格一律按**最终跨境物流费**计算;“不计抛物流费”只是透明的对照项,不能错误地当成成本。用户点击“展开计算过程”后看到每一步公式、阈值、取整方式和输入来源;点击“保存为定价方案”可把当前可复用参数带到方案编辑器,但商品成本、尺寸和单次临时佣金覆盖不应成为默认值。
|
||||||
|
|
||||||
|
### 4.3 缺失与异常
|
||||||
|
|
||||||
|
- 缺少类目佣金、有效运价、汇率、采购成本或有效包裹数据时,不输出建议售价,指出缺失项。
|
||||||
|
- 包裹尺寸不适用于某个运输方式、超过重量/尺寸上限或无法得到计费重时,该方式单独标为不可报价,不影响其他方式。
|
||||||
|
- 临时覆盖的佣金、汇率或运价必须以醒目标记,并且保存结果时固化为快照。
|
||||||
|
|
||||||
|
## 5. 定价方案
|
||||||
|
|
||||||
|
### 5.1 作用与范围
|
||||||
|
|
||||||
|
定价方案属于全局当前店铺;一个门店可以维护多个方案,例如“CEL 常规”“CEL 大件”“测试利润率”。门店可设置一个默认方案,商品编辑页的新 SKU 自动引用它。方案修改只影响后续试算;已有 SKU 的确认价格保留原快照,不能静默重算。
|
||||||
|
|
||||||
|
方案的可配置字段:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 方案名称、备注、启用状态 | 用于识别和管理。 |
|
||||||
|
| 目标店铺、币种、履约模式 | 与平台规则、汇率和发布上下文绑定。 |
|
||||||
|
| 跨境物流商 | 首期 CEL;未来可扩展。 |
|
||||||
|
| 默认运输方式 | 空运、陆空、陆运之一;只可选择该服务商当前可用的运输方式。 |
|
||||||
|
| 参与比较的运输方式 | 即时试算默认勾选项;不等于 SKU 自动采用的方式。 |
|
||||||
|
| 国内运费 + 代贴单 | 固定金额。 |
|
||||||
|
| 广告费占比 | 销售价的百分比。 |
|
||||||
|
| 尾程(Ozon 代理费) | 销售价的百分比。 |
|
||||||
|
| 提现费 | 销售价的百分比。 |
|
||||||
|
| 其他(货损等) | 销售价的百分比;后续可拆项。 |
|
||||||
|
| 期望加价率 | 见第 7 节的统一口径。 |
|
||||||
|
| 划线折扣率 | 见第 7 节的统一口径。 |
|
||||||
|
| 汇率策略 | 使用系统汇率、固定汇率,或允许每次报价覆盖。 |
|
||||||
|
|
||||||
|
类目佣金不建议固定写死在方案中:它随商品类目选择;方案只允许设“无规则时的临时覆盖是否可用”。
|
||||||
|
|
||||||
|
### 5.2 商品编辑页的 SKU 定价
|
||||||
|
|
||||||
|
SKU 的“定价”按钮打开轻量报价抽屉/弹窗,而不是跳转到完整工具页。流程:
|
||||||
|
|
||||||
|
1. 自动读取当前商品的 Ozon 类目、目标店铺、SKU 采购成本和**有效包装物流规格**。
|
||||||
|
2. 自动加载店铺默认方案;用户可临时切换至该店铺其他启用方案,也可临时改变运输方式。
|
||||||
|
3. 显示默认运输方式的结果,并可展开比较该方案中允许的其他方式。
|
||||||
|
4. 用户确认一项运输方式和结果后,回填 SKU 的销售价、划线价、采用的运输方式及不可变更的报价快照。
|
||||||
|
|
||||||
|
临时切换方案或运输方式只影响本次 SKU 报价和确认后的报价快照,不会修改门店默认方案。SKU 缺少采购成本、包装重量或包装长宽高时,不能使用父商品的实物规格替代;若存在父商品的包装默认规格,则按既有继承规则先解析为有效包装规格。缺失或口径待确认时只允许修正数据,不允许确认价格。
|
||||||
|
|
||||||
|
## 6. 物流服务商与运价规则的预留
|
||||||
|
|
||||||
|
第一期 UI 仅暴露 CEL,但规则层必须支持以下信息,以适配不同服务商和运输方式:
|
||||||
|
|
||||||
|
| 维度 | 需要预留的信息 |
|
||||||
|
|---|---|
|
||||||
|
| 服务商 | 名称、代码、状态、结算币种、适用目的地/仓、备注、生效时间。 |
|
||||||
|
| 运输方式 | 名称、代码(如 air/land_air/land)、时效说明、状态、可承运类目或限制。 |
|
||||||
|
| 计费重量 | 实重单位、体积重公式/除数、计费规则(实重、体积重、两者取大、阶梯等)、进位粒度和最小计费重。 |
|
||||||
|
| 运价阶梯 | 起止计费重、固定费、每 kg/每续重单价、最低收费、币种、生效区间。 |
|
||||||
|
| 附加费 | 偏远地区、燃油、超长超重、带电/敏感货、保险、清关等;支持固定额或比例、是否纳入利润计算。 |
|
||||||
|
| 限制 | 重量/尺寸/体积上限、禁运类目、运输方式特有的校验与错误信息。 |
|
||||||
|
| 版本与来源 | 规则来源、文件/人工录入、创建人、审核状态、生效时间;任何报价引用准确版本。 |
|
||||||
|
|
||||||
|
CEL 的 Ozon-rFBS 资费表已提取为待审核规则,见 [`cel-ozon-rfbs-rates-review.md`](./cel-ozon-rfbs-rates-review.md)。在其中的源表疑点、计费重粒度和实际账单复算完成前,首期不得把这些费率作为正式生产报价启用。
|
||||||
|
|
||||||
|
## 7. 已确认的两个公式口径
|
||||||
|
|
||||||
|
旧项目试算页的“净利率”是以采购成本为基数的加价率,且以固定 15.5%/21.5% 的平台抽成估算。新工具要拆开佣金、广告、物流和其他费用,不能继续沿用该固定比例。
|
||||||
|
|
||||||
|
| 字段 | 建议定义 | 建议公式 |
|
||||||
|
|---|---|---|
|
||||||
|
| 期望加价率 | 净利润相对采购成本的比例 | `目标净利润 = 采购成本 × 期望加价率`。建议销售价由“固定成本 + 目标净利润”除以 `1 − 平台佣金率 − 广告费率 − 尾程费率 − 提现费率 − 其他费率` 得到;采购成本按汇率转换到销售币种。 |
|
||||||
|
| 划线折扣率 | 买家相对划线价实际享受的折扣率 | `划线价 = 销售价 ÷ (1 − 划线折扣率)`;折扣必须大于等于 0 且小于 100%。 |
|
||||||
|
|
||||||
|
其中固定成本为:采购成本(按汇率换算)+ 最终跨境物流费 + 国内运费/代贴单 + 所有固定附加费。平台佣金、广告费、尾程、提现和其他百分比费用均以销售价为基数。
|
||||||
|
|
||||||
|
新项目只使用“划线折扣率”,不保留、不兼容也不迁移旧试算页的“划线价倍数/售价加价率”计算方式。所有新报价均按 `划线价 = 销售价 ÷ (1 − 划线折扣率)` 计算并保存快照。
|
||||||
|
|
||||||
|
## 8. 与旧试算页的继承关系
|
||||||
|
|
||||||
|
旧项目的计价模块可以复用其“按 SKU 用包装规格计算、保存报价快照、单个 SKU 出错不阻断其他 SKU”的经验;但不复用以下业务常量:物流等级 `low/high/high2`、固定平台佣金 12%/18%、固定“提现费及其他”约 3.5%,以及将实物尺寸当物流尺寸的旧数据。
|
||||||
|
|
||||||
|
Atlas 的定价工具以服务商运输方式、类目佣金、包裹物流规格和版本化费率为唯一依据。
|
||||||
|
|
||||||
|
## 9. 待确认事项
|
||||||
|
|
||||||
|
1. 请提供首批 CEL 的空运、陆空、陆运价表和计费规则(计费重、体积重除数、阶梯、附加费、限制、生效日期);拿到后再将其整理为可录入的规则模板。
|
||||||
Reference in New Issue
Block a user