From 0a303414d817c6381745ff00839fedb7b9dc0477 Mon Sep 17 00:00:00 2001 From: R524809 Date: Tue, 29 Sep 2026 17:50:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(edit):=20=E5=AE=9A=E4=BB=B7=E8=AF=95?= =?UTF-8?q?=E7=AE=97=E5=BC=80=E5=8F=91=E3=80=81SKU=20=E6=A0=B7=E5=BC=8F?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/config/app-config.module.ts | 8 +- apps/api/src/main.ts | 5 + .../src/pricing/commission-import-parser.ts | 106 ++ .../pricing/commission-import.controller.ts | 84 ++ .../src/pricing/commission-import.service.ts | 347 +++++ apps/api/src/pricing/commission-rules.ts | 28 + apps/api/src/pricing/pricing-engine.ts | 7 + apps/api/src/pricing/pricing.controller.ts | 9 + apps/api/src/pricing/pricing.module.ts | 5 +- apps/api/src/pricing/pricing.service.ts | 47 +- .../product-editor/product-editor.models.ts | 2 + .../product-editor/product-editor.service.ts | 21 +- .../product-editor.validation.ts | 9 - apps/api/test/commission-import.test.ts | 466 +++++++ apps/api/test/pricing-draft-mode.unit.test.ts | 9 +- apps/api/test/pricing-engine.unit.test.ts | 82 ++ apps/api/test/pricing.e2e.test.ts | 18 +- apps/api/test/product-editor.e2e.test.ts | 126 ++ apps/web/src/App.tsx | 16 +- .../src/components/PackageIntegerInput.tsx | 28 + .../components/pricing/PricingCalculator.tsx | 124 +- .../web/src/components/pricing/SkuPricing.tsx | 465 ++++--- .../pricing/pricingInputValidation.ts | 23 + apps/web/src/layouts/SystemAdminLayout.tsx | 9 +- apps/web/src/pages/pricing/PricingPage.tsx | 15 +- .../src/pages/products/ProductEditPage.tsx | 239 ++-- .../components/ProductAttributeSection.css | 24 +- .../components/ProductMainInfoSection.css | 16 +- .../components/ProductMainInfoSection.tsx | 143 +- .../components/ProductVariantSection.css | 809 ++++++++---- .../components/ProductVariantSection.tsx | 1174 ++++++++--------- .../products/components/SectionTitle.tsx | 2 +- .../products/components/SkuCardPreview.tsx | 34 + .../components/SkuImageEditorModal.css | 2 +- .../products/components/SkuInfoPanel.tsx | 256 ++++ .../products/components/SkuMediaPanel.tsx | 78 ++ .../components/SkuSingleAiEditDrawer.css | 4 +- .../components/SkuWatermarkPickerModal.css | 10 +- .../pages/system/SystemCommissionsPage.tsx | 477 +++++++ apps/web/src/router/index.tsx | 2 + apps/web/src/services/categoryCommissions.ts | 21 + docs/api/category-commissions.md | 24 + docs/api/pricing.md | 14 +- docs/api/product-editor-and-publishing.md | 15 +- .../category-commission-import-plan.md | 72 + docs/project/HANDOFF.md | 19 +- .../contracts/src/category-commissions.ts | 81 ++ packages/contracts/src/index.ts | 1 + packages/contracts/src/pricing.ts | 2 + packages/contracts/src/product-editor.ts | 4 + .../052_category_commission_imports.sql | 59 + .../migrations/053_package_spec_modes.sql | 30 + 52 files changed, 4278 insertions(+), 1393 deletions(-) create mode 100644 apps/api/src/pricing/commission-import-parser.ts create mode 100644 apps/api/src/pricing/commission-import.controller.ts create mode 100644 apps/api/src/pricing/commission-import.service.ts create mode 100644 apps/api/src/pricing/commission-rules.ts create mode 100644 apps/api/test/commission-import.test.ts create mode 100644 apps/web/src/components/PackageIntegerInput.tsx create mode 100644 apps/web/src/components/pricing/pricingInputValidation.ts create mode 100644 apps/web/src/pages/products/components/SkuCardPreview.tsx create mode 100644 apps/web/src/pages/products/components/SkuInfoPanel.tsx create mode 100644 apps/web/src/pages/products/components/SkuMediaPanel.tsx create mode 100644 apps/web/src/pages/system/SystemCommissionsPage.tsx create mode 100644 apps/web/src/services/categoryCommissions.ts create mode 100644 docs/api/category-commissions.md create mode 100644 docs/product/category-commission-import-plan.md create mode 100644 packages/contracts/src/category-commissions.ts create mode 100644 packages/database/migrations/052_category_commission_imports.sql create mode 100644 packages/database/migrations/053_package_spec_modes.sql diff --git a/apps/api/src/config/app-config.module.ts b/apps/api/src/config/app-config.module.ts index 4ce2891..869b0ea 100644 --- a/apps/api/src/config/app-config.module.ts +++ b/apps/api/src/config/app-config.module.ts @@ -101,11 +101,7 @@ export function draftPricingEnabled( nodeEnvironment: Environment['NODE_ENV'], setting: Environment['PRICING_ALLOW_DRAFT_RATES'], ): boolean { - return ( - nodeEnvironment !== 'production' && - (setting === 'true' || - (nodeEnvironment === 'development' && setting === 'auto')) - ); + return setting === 'true' || (setting === 'auto' && nodeEnvironment !== 'test'); } export type AppConfig = Omit< Environment, @@ -152,7 +148,7 @@ function createAppConfig(environment: Environment): AppConfig { objectStorageSecretKey, objectStorageForcePathStyle: environment.OBJECT_STORAGE_FORCE_PATH_STYLE === 'true', - // 草稿费率仅用于本地商品编辑联调,生产配置无法开启。 + // 草稿费率默认可参与报价;仍可通过环境配置限制新试算。 pricingAllowDraftRates: draftPricingEnabled( environment.NODE_ENV, environment.PRICING_ALLOW_DRAFT_RATES, diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 5ec30c3..8c27da4 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -58,6 +58,11 @@ export async function createApplication() { }); // Helmet、body 限制、请求 ID 与全局错误过滤器必须在业务路由前注册。 app.use(helmet({ contentSecurityPolicy: false })); + // 批量佣金文件可达 2 MiB,专用入口预留元数据空间,其他 API 保留原限制。 + app.useBodyParser('json', { + limit: 3 * 1024 * 1024, + type: (req) => (req as Request).path.startsWith('/api/system/pricing/commissions') && Boolean((req as Request).is('application/json')), + }); app.useBodyParser('json', { limit: config.API_BODY_LIMIT_BYTES }); app.useBodyParser('urlencoded', { limit: config.API_BODY_LIMIT_BYTES, diff --git a/apps/api/src/pricing/commission-import-parser.ts b/apps/api/src/pricing/commission-import-parser.ts new file mode 100644 index 0000000..befdbf3 --- /dev/null +++ b/apps/api/src/pricing/commission-import-parser.ts @@ -0,0 +1,106 @@ +import { z } from 'zod'; +import type { CommissionImportCategory } from '@atlas/contracts'; +import { ApiError } from '../common/api-error.js'; +import { D } from './pricing-engine.js'; + +const sourceId = z + .union([ + z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + z.string().regex(/^[1-9]\d{0,17}$/), + ]) + .transform(String); +const leaf = z + .object({ + id: z.union([z.string(), z.number()]), + label: z.string().max(200), + value: z.string().max(50), + }) + .strict(); +const category = z + .object({ + id: z.union([z.string(), z.number()]), + label: z.string().trim().min(1).max(200), + value: z.string().max(200), + cate_id: sourceId, + children: z.array(leaf).length(3), + }) + .strict(); +const root = category + .omit({ children: true }) + .extend({ children: z.array(category).min(1).max(2000) }); +const documentSchema = z + .object({ + code: z.literal(1), + msg: z.string(), + data: z.array(root).min(1).max(100), + }) + .strict(); +const fail = (message: string): never => { + throw new ApiError(422, 'COMMISSION_IMPORT_INVALID', message); +}; + +/** 仅在导入时解析来源文字;报价读取结构化区间,不解析展示标签。 */ +export function parseCommissionDocument( + document: unknown, +): CommissionImportCategory[] { + if (Buffer.byteLength(JSON.stringify(document) ?? '') > 2 * 1024 * 1024) + fail('JSON 不能超过 2 MiB'); + const parsed = documentSchema.safeParse(document); + if (!parsed.success) + return fail( + 'JSON 格式不正确:应包含一级类目、二级类目和每类完整的三个价格档位', + ); + const ids = new Set(); + const result: CommissionImportCategory[] = []; + for (const group of parsed.data.data) { + if (ids.has(group.cate_id)) fail(`类目 ID 重复:${group.cate_id}`); + ids.add(group.cate_id); + for (const item of group.children) { + if (ids.has(item.cate_id)) fail(`类目 ID 重复:${item.cate_id}`); + ids.add(item.cate_id); + const bands = item.children.map((tier, index) => { + const value = /^([123]),(\d{1,2}(?:\.\d{1,2})?)$/.exec( + tier.value, + ); + const prefix = [ + '售价 ≤ 1500₽', + '1500₽ < 售价 ≤ 5000₽', + '售价 > 5000₽', + ][index]; + const label = /^(.*?)\s*\((\d{1,2}(?:\.\d{1,2})?)%\)$/.exec( + tier.label, + ); + if ( + !value || + !label || + value[1] !== String(index + 1) || + label[1].trim() !== prefix || + !D(value[2]).eq(label[2]) + ) + return fail( + `${item.label}:档位或标签百分比与 value 不一致`, + ); + return { + min_price: ['0', '1500', '5000'][index], + max_price: ['1500', '5000', null][index], + min_inclusive: false, + max_inclusive: index < 2, + commission_rate: D(value[2]).div(100).toFixed(6), + }; + }); + result.push({ + source_category_id: item.cate_id, + parent_source_id: group.cate_id, + name: item.label, + parent_name: group.label, + bands, + status: 'eligible', + issues: [], + change: 'added', + mapped_types: 0, + }); + } + } + if (result.length > 2000) fail('二级类目不能超过 2,000 个'); + return result; +} diff --git a/apps/api/src/pricing/commission-import.controller.ts b/apps/api/src/pricing/commission-import.controller.ts new file mode 100644 index 0000000..f949d4a --- /dev/null +++ b/apps/api/src/pricing/commission-import.controller.ts @@ -0,0 +1,84 @@ +import { + Body, + Controller, + Get, + Headers, + Inject, + Param, + Post, + Query, + Req, + Res, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { z } from 'zod'; +import { + commissionImportRequestSchema, + commissionImportPublishSchema, + type Workspace, +} from '@atlas/contracts'; +import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js'; +import type { AuthenticatedRequest } from '../auth/auth.guard.js'; +import { ApiError } from '../common/api-error.js'; +import { SystemAdminGuard } from '../platform-catalog-admin/system-admin.guard.js'; +import { CommissionImportService } from './commission-import.service.js'; +function parse(schema: z.ZodType, value: unknown): T { + const r = schema.safeParse(value); + if (!r.success) + throw new ApiError( + 422, + 'VALIDATION_ERROR', + '请检查平台、版本名称、来源、采集日期及确认项', + r.error.issues.map((i) => ({ + field: i.path.join('.'), + message: i.message, + })), + ); + return r.data; +} +@ApiTags('System commissions') +@ApiBearerAuth('access-token') +@UseGuards(SystemAdminGuard) +@Controller('system/pricing/commissions') +export class CommissionImportController { + constructor( + @Inject(CommissionImportService) + private readonly service: CommissionImportService, + ) {} + @Get() async list(@Query('platform_id') id: string) { + return { data: await this.service.list(parse(z.uuid(), id)) }; + } + @Get(':id') async detail(@Param('id') id: string) { + return { data: await this.service.detail(parse(z.uuid(), id)) }; + } + @Post('preview') async preview(@Body() body: unknown) { + return { + data: await this.service.preview( + parse(commissionImportRequestSchema, body), + ), + }; + } + @Post('imports') async publish( + @Body() body: unknown, + @Req() req: AuthenticatedRequest, + @CurrentWorkspace() w: Workspace, + @Headers('idempotency-key') key: string | undefined, + @Res({ passthrough: true }) res: { status: (n: number) => unknown }, + ) { + if (!key) + throw new ApiError( + 400, + 'IDEMPOTENCY_KEY_REQUIRED', + '需要 Idempotency-Key', + ); + const r = await this.service.publish( + w.id, + req.auth.userId, + parse(z.uuid(), key), + parse(commissionImportPublishSchema, body), + ); + res.status(r.status); + return r.body; + } +} diff --git a/apps/api/src/pricing/commission-import.service.ts b/apps/api/src/pricing/commission-import.service.ts new file mode 100644 index 0000000..d72d19d --- /dev/null +++ b/apps/api/src/pricing/commission-import.service.ts @@ -0,0 +1,347 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import type { Database, DatabaseTransaction } from '@atlas/database'; +import type { + CommissionImportRequest, + CommissionImportPublish, + CommissionImportCategory, + CommissionImportPreview, + CommissionImportVersion, + CommissionImportDetail, +} from '@atlas/contracts'; +import { DATABASE } from '../database/database.module.js'; +import { IdempotencyService } from '../common/idempotency.service.js'; +import { ApiError } from '../common/api-error.js'; +import { fingerprint } from './pricing-engine.js'; +import { parseCommissionDocument } from './commission-import-parser.js'; +type Tx = DatabaseTransaction; +type CatalogRow = { + id: string; + external_category_id: string; + external_type_id: string; + parent_external_id: string | null; + is_active: boolean; +}; +const columns = + 'id,platform_id,version,source_reference,captured_on::text,activated_at,is_current,summary'; +const inputFields = (v: CommissionImportRequest): CommissionImportRequest => ({ + platform_id: v.platform_id, + version: v.version, + source_reference: v.source_reference, + captured_on: v.captured_on, + document: v.document, +}); + +@Injectable() +export class CommissionImportService { + constructor( + @Inject(DATABASE) private readonly db: Database, + @Inject(IdempotencyService) + private readonly idempotency: IdempotencyService, + ) {} + + async list(platformId: string): Promise { + return ( + await this.db.query( + `SELECT ${columns} FROM atlas.commission_imports WHERE platform_id=$1 ORDER BY activated_at DESC,id`, + [platformId], + ) + ).rows; + } + async detail(id: string): Promise { + const row = ( + await this.db.query( + `SELECT ${columns},categories FROM atlas.commission_imports WHERE id=$1`, + [id], + ) + ).rows[0]; + if (!row) + throw new ApiError( + 404, + 'COMMISSION_IMPORT_NOT_FOUND', + '佣金版本不存在', + ); + return row; + } + async preview(input: CommissionImportRequest) { + return this.db.transaction( + async (tx) => (await this.inspect(tx, input)).preview, + ); + } + private async inspect( + tx: Tx, + input: CommissionImportRequest, + lock = false, + ) { + const platform = ( + await tx.query<{ code: string }>( + `SELECT code FROM atlas.platforms WHERE id=$1 ${lock ? 'FOR UPDATE' : ''}`, + [input.platform_id], + ) + ).rows[0]; + if (platform?.code.toUpperCase() !== 'OZON') + throw new ApiError( + 422, + 'COMMISSION_PLATFORM_UNSUPPORTED', + '当前仅支持 Ozon 类目佣金', + ); + if (input.captured_on > new Date().toISOString().slice(0, 10)) + throw new ApiError( + 422, + 'COMMISSION_IMPORT_INVALID', + '采集日期不能晚于今天', + ); + const categories = parseCommissionDocument(input.document); + const rows = ( + await tx.query( + `SELECT c.id,c.external_category_id,c.external_type_id,c.is_active,p.external_category_id AS parent_external_id + FROM atlas.platform_product_categories c LEFT JOIN atlas.platform_product_categories p ON p.id=c.parent_id + WHERE c.platform_id=$1 ORDER BY c.id ${lock ? 'FOR SHARE OF c' : ''}`, + [input.platform_id], + ) + ).rows; + const previous = ( + await tx.query<{ + id: string; + categories: CommissionImportCategory[]; + }>( + `SELECT id,categories FROM atlas.commission_imports WHERE platform_id=$1 AND is_current`, + [input.platform_id], + ) + ).rows[0]; + const old = new Map( + (previous?.categories ?? []) + .filter((c) => c.status === 'eligible') + .map((c) => [c.source_category_id, c]), + ); + const mappings = new Map(); + for (const c of categories) { + const matching = rows.filter( + (r) => r.external_category_id === c.source_category_id, + ); + const parent = matching.filter( + (r) => !r.external_type_id || r.external_type_id === '0', + ); + const types = matching.filter( + (r) => + r.is_active && + r.external_type_id && + r.external_type_id !== '0', + ); + if (parent.length !== 1) + c.issues.push('平台目录中没有唯一对应的类目'); + else if (parent[0].parent_external_id !== c.parent_source_id) + c.issues.push('来源一级父类目与平台目录不一致'); + if (!types.length) c.issues.push('没有可映射的有效商品类型'); + c.status = c.issues.length ? 'excluded' : 'eligible'; + c.mapped_types = c.status === 'eligible' ? types.length : 0; + const before = old.get(c.source_category_id); + c.change = !before + ? 'added' + : fingerprint(before.bands) === fingerprint(c.bands) + ? 'unchanged' + : 'changed'; + if (c.status === 'eligible') + mappings.set( + c.source_category_id, + types.map((t) => t.id), + ); + } + const eligible = categories.filter((c) => c.status === 'eligible'); + const summary = { + categories: categories.length, + rules: categories.reduce((n, c) => n + c.bands.length, 0), + eligible: eligible.length, + excluded: categories.length - eligible.length, + mapped_types: eligible.reduce((n, c) => n + c.mapped_types, 0), + removed: [...old.keys()].filter((id) => !mappings.has(id)).length, + }; + const preview: CommissionImportPreview = { + categories, + summary, + previous_import_id: previous?.id ?? null, + preview_hash: fingerprint({ + input: inputFields(input), + categories, + mappings: [...mappings], + previous: previous?.id ?? null, + }), + }; + return { preview, mappings }; + } + async publish( + workspaceId: string, + userId: string, + key: string, + input: CommissionImportPublish, + ) { + return this.idempotency.run( + { + workspaceId, + userId, + key, + method: 'POST', + path: '/api/system/pricing/commissions/imports', + request: input, + }, + async (tx) => { + // 发布时重新查询有效管理员授权,避免仅信任旧 JWT。 + const grant = await tx.query( + `SELECT g.user_id FROM atlas.system_admin_grants g JOIN atlas.users u ON u.id=g.user_id WHERE g.user_id=$1 AND g.revoked_at IS NULL AND u.status='active' FOR SHARE OF u`, + [userId], + ); + if (!grant.rowCount) + throw new ApiError( + 403, + 'SYSTEM_ADMIN_REQUIRED', + '需要系统管理员权限', + ); + const { preview, mappings } = await this.inspect( + tx, + input, + true, + ); + if (preview.preview_hash !== input.preview_hash) + throw new ApiError( + 409, + 'COMMISSION_PREVIEW_STALE', + '目录、当前版本或输入已变化,请重新预览', + ); + if (!preview.summary.eligible) + throw new ApiError( + 422, + 'COMMISSION_IMPORT_INVALID', + '没有可启用的类目,请核对异常项', + ); + const contentHash = fingerprint(input.document); + if ( + ( + await tx.query( + `SELECT id FROM atlas.commission_imports WHERE platform_id=$1 AND (content_hash=$2 OR version=$3)`, + [input.platform_id, contentHash, input.version], + ) + ).rowCount + ) + throw new ApiError( + 409, + 'COMMISSION_IMPORT_EXISTS', + '此版本名称或 JSON 内容已导入', + ); + const typeIds = [...mappings.values()].flat(); + if ( + ( + await tx.query( + `SELECT id FROM atlas.platform_product_category_commission_mappings WHERE platform_id=$1 AND platform_product_category_id=ANY($2::uuid[]) AND fulfillment='rfbs' AND market_code='RU' AND status='active' AND (effective_to IS NULL OR effective_to>now()) AND import_id IS NULL FOR SHARE`, + [input.platform_id, typeIds], + ) + ).rowCount + ) + throw new ApiError( + 409, + 'COMMISSION_MAPPING_CONFLICT', + '部分类型已有其他来源的有效佣金映射,请先核对', + ); + if (preview.previous_import_id) { + await tx.query( + `UPDATE atlas.commission_category_rules SET status='archived',effective_to=now() WHERE import_id=$1 AND effective_to IS NULL`, + [preview.previous_import_id], + ); + await tx.query( + `UPDATE atlas.platform_product_category_commission_mappings SET status='archived',effective_to=now() WHERE import_id=$1 AND effective_to IS NULL`, + [preview.previous_import_id], + ); + await tx.query( + `UPDATE atlas.commission_imports SET is_current=false WHERE id=$1`, + [preview.previous_import_id], + ); + } + const id = randomUUID(); + const saved = ( + await tx.query( + `INSERT INTO atlas.commission_imports(id,platform_id,version,source_reference,captured_on,document,content_hash,categories,summary,activated_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING ${columns}`, + [ + id, + input.platform_id, + input.version, + input.source_reference, + input.captured_on, + JSON.stringify(input.document), + contentHash, + JSON.stringify(preview.categories), + JSON.stringify(preview.summary), + userId, + ], + ) + ).rows[0]; + const roots = new Map(); + for (const c of preview.categories.filter( + (c) => c.status === 'eligible', + )) { + let rootId = roots.get(c.parent_source_id); + if (!rootId) { + rootId = await this.category( + tx, + input.platform_id, + c.parent_source_id, + c.parent_name, + `毛子ERP / ${c.parent_name} (${c.parent_source_id})`, + null, + ); + roots.set(c.parent_source_id, rootId); + } + const categoryId = await this.category( + tx, + input.platform_id, + c.source_category_id, + c.name, + `毛子ERP / ${c.parent_name} / ${c.name} (${c.source_category_id})`, + rootId, + ); + await tx.query( + `INSERT INTO atlas.commission_category_rules(commission_category_id,fulfillment,market_code,currency_code,min_price,max_price,min_inclusive,max_inclusive,commission_rate,source,version,status,effective_from,import_id) + SELECT $1,'rfbs','RU','RUB',min_price::numeric,max_price::numeric,min_inclusive,max_inclusive,commission_rate::numeric,'competitor_capture',$2,'active',now(),$3 FROM jsonb_to_recordset($4::jsonb) AS b(min_price text,max_price text,min_inclusive boolean,max_inclusive boolean,commission_rate text)`, + [ + categoryId, + input.version, + id, + JSON.stringify(c.bands), + ], + ); + await tx.query( + `INSERT INTO atlas.platform_product_category_commission_mappings(platform_id,platform_product_category_id,commission_category_id,fulfillment,market_code,priority,source,status,effective_from,import_id) + SELECT $1,unnest($2::uuid[]),$3,'rfbs','RU',100,'competitor_capture','active',now(),$4`, + [ + input.platform_id, + mappings.get(c.source_category_id), + categoryId, + id, + ], + ); + } + return { status: 201, body: { data: saved } }; + }, + ); + } + private async category( + tx: Tx, + platform: string, + key: string, + name: string, + path: string, + parent: string | null, + ): Promise { + const existing = ( + await tx.query<{ id: string }>( + `SELECT id FROM atlas.commission_categories WHERE platform_id=$1 AND source_category_key=$2`, + [platform, `maozierp:${key}`], + ) + ).rows[0]; + if (existing) return existing.id; + return ( + await tx.query<{ id: string }>( + `INSERT INTO atlas.commission_categories(platform_id,parent_id,source_category_key,name,path,source,status) VALUES($1,$2,$3,$4,$5,'competitor_capture','active') RETURNING id`, + [platform, parent, `maozierp:${key}`, name, path], + ) + ).rows[0].id; + } +} diff --git a/apps/api/src/pricing/commission-rules.ts b/apps/api/src/pricing/commission-rules.ts new file mode 100644 index 0000000..e28e398 --- /dev/null +++ b/apps/api/src/pricing/commission-rules.ts @@ -0,0 +1,28 @@ +import type { DatabaseTransaction } from '@atlas/database'; +import type { CommissionRule } from './pricing-engine.js'; + +/** 查询与正式报价共用同一组已启用规则,避免页面显示和实际算价使用不同费率。 */ +export async function readCommissionRules( + tx: DatabaseTransaction, + platformId: string, + categoryId: string, + fulfillment: string, +) { + const mapping = ( + await tx.query<{ commission_category_id: string }>( + `SELECT * FROM atlas.platform_product_category_commission_mappings WHERE platform_id=$1 AND platform_product_category_id=$2 AND fulfillment=$3 AND market_code='RU' AND status='active' AND effective_from<=now() AND (effective_to IS NULL OR effective_to>now()) ORDER BY priority,effective_from DESC,id LIMIT 1 FOR SHARE`, + [platformId, categoryId, fulfillment], + ) + ).rows[0]; + const commissions = mapping + ? ( + await tx.query( + `SELECT r.*,c.name AS category_name FROM atlas.commission_category_rules r JOIN atlas.commission_categories c ON c.id=r.commission_category_id + WHERE r.commission_category_id=$1 AND r.fulfillment=$2 AND r.market_code='RU' AND r.currency_code IN ('CNY','RUB') + AND r.status='active' AND r.effective_from<=now() AND (r.effective_to IS NULL OR r.effective_to>now()) ORDER BY r.min_price NULLS FIRST,r.id FOR SHARE OF r,c`, + [mapping.commission_category_id, fulfillment], + ) + ).rows + : []; + return { mapping, commissions }; +} diff --git a/apps/api/src/pricing/pricing-engine.ts b/apps/api/src/pricing/pricing-engine.ts index 0a07675..9e4b553 100644 --- a/apps/api/src/pricing/pricing-engine.ts +++ b/apps/api/src/pricing/pricing-engine.ts @@ -52,6 +52,9 @@ export type LogisticsRule = { volume_limit_round_scale: number | null; }; export type CommissionRule = { + category_name?: string; + source?: string; + effective_from?: string | Date; id: string | null; currency_code: string; min_price: string | null; @@ -254,6 +257,10 @@ export function calculatePricing( logistics_rate_rule_id: r.id, commission_rule_id: c.id, commission_rate: c.commission_rate, + ...(c.id ? {commission_details: { + category_name: c.category_name ?? '',currency_code:c.currency_code,version:c.version,source:c.source ?? 'manual_rule', + min_price:c.min_price,max_price:c.max_price,min_inclusive:c.min_inclusive,max_inclusive:c.max_inclusive,commission_rate:c.commission_rate, + }} : {}), selling_price: rounded.toFixed(2), list_price: list.toFixed(2), currency_code: currency, diff --git a/apps/api/src/pricing/pricing.controller.ts b/apps/api/src/pricing/pricing.controller.ts index f820bf6..bf0e68d 100644 --- a/apps/api/src/pricing/pricing.controller.ts +++ b/apps/api/src/pricing/pricing.controller.ts @@ -54,6 +54,15 @@ export class PricingController { constructor( @Inject(PricingService) private readonly service: PricingService, ) {} + @Get('pricing/commission-rules') async commissionRules( + @CurrentWorkspace() w: Workspace, + @Query('store_id') store: string, + @Query('platform_product_category_id') category: string | undefined, + @Query('description_category_id') external: string | undefined, + ) { + if (Boolean(category) === Boolean(external)) throw new ApiError(422,'VALIDATION_ERROR','请指定一种类目 ID'); + return {data:await this.service.commissionRules(w.id,parse(z.uuid(),store),category ? parse(z.uuid(),category) : undefined,external ? parse(z.string().regex(/^[1-9]\d{0,17}$/),external) : undefined)}; + } @Get('pricing/categories') async categories( @CurrentWorkspace() w: Workspace, @Query('store_id') store: string, diff --git a/apps/api/src/pricing/pricing.module.ts b/apps/api/src/pricing/pricing.module.ts index 87258eb..be6c1e9 100644 --- a/apps/api/src/pricing/pricing.module.ts +++ b/apps/api/src/pricing/pricing.module.ts @@ -1,3 +1,5 @@ +import { CommissionImportController } from './commission-import.controller.js'; +import { CommissionImportService } from './commission-import.service.js'; import { Module } from '@nestjs/common'; import { IdempotencyService } from '../common/idempotency.service.js'; import { PricingController } from './pricing.controller.js'; @@ -6,9 +8,10 @@ import { PricingAdminController } from './pricing-admin.controller.js'; import { PricingAdminService } from './pricing-admin.service.js'; import { SystemAdminGuard } from '../platform-catalog-admin/system-admin.guard.js'; @Module({ - controllers: [PricingController, PricingAdminController], + controllers: [PricingController, PricingAdminController, CommissionImportController], providers: [ PricingService, + CommissionImportService, PricingAdminService, SystemAdminGuard, IdempotencyService, diff --git a/apps/api/src/pricing/pricing.service.ts b/apps/api/src/pricing/pricing.service.ts index c2995f8..74a9c99 100644 --- a/apps/api/src/pricing/pricing.service.ts +++ b/apps/api/src/pricing/pricing.service.ts @@ -1,3 +1,5 @@ +import { readCommissionRules } from './commission-rules.js'; +import type { CommissionRulesResult } from '@atlas/contracts'; import { Inject, Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; import type { Database, DatabaseTransaction } from '@atlas/database'; @@ -121,6 +123,24 @@ export class PricingService { }, })); } + async commissionRules(workspaceId: string, storeId: string, categoryId?: string, externalId?: string): Promise { + return this.db.workspaceTransaction(workspaceId, async tx => { + const store = await this.store(tx, storeId); + const categories = (await tx.query<{id:string}>(`SELECT id FROM atlas.platform_product_categories WHERE platform_id=$1 AND is_active AND ($2::uuid IS NULL OR id=$2) AND ($3::text IS NULL OR external_category_id=$3) ORDER BY id`, [store.platform_id, categoryId ?? null, externalId ?? null])).rows; + if (!categories.length) throw new ApiError(422, 'PRICING_CATEGORY_REQUIRED', '请选择当前平台的有效类目'); + let resolvedCategoryId = categories[0].id; + // 外部类目下可以有多个类型,但不能将相互冲突的映射折叠成一个费率。 + if (externalId) { + const mapped = await tx.query<{id:string; platform_product_category_id:string}>(`SELECT DISTINCT commission_category_id AS id,platform_product_category_id FROM atlas.platform_product_category_commission_mappings WHERE platform_id=$1 AND platform_product_category_id=ANY($2::uuid[]) AND fulfillment='rfbs' AND market_code='RU' AND status='active' AND effective_from<=now() AND (effective_to IS NULL OR effective_to>now())`,[store.platform_id,categories.map(c=>c.id)]); + if (new Set(mapped.rows.map(row => row.id)).size>1) throw new ApiError(422,'COMMISSION_MAPPING_CONFLICT','此类目下的商品类型使用不同规则,请指定具体商品类型'); + resolvedCategoryId = mapped.rows[0]?.platform_product_category_id ?? resolvedCategoryId; + } + const {commissions} = await readCommissionRules(tx,store.platform_id,resolvedCategoryId,'rfbs'); + if (!commissions.length) throw new ApiError(422,'PRICING_COMMISSION_UNAVAILABLE','该类目没有有效佣金规则'); + const first=commissions[0]; + return {category_name:first.category_name ?? '',currency_code:first.currency_code,version:first.version,source:first.source ?? 'manual_rule',effective_from:new Date(first.effective_from!).toISOString(),bands:commissions.map(c=>({id:c.id!,min_price:c.min_price,max_price:c.max_price,min_inclusive:c.min_inclusive,max_inclusive:c.max_inclusive,commission_rate:c.commission_rate}))}; + }); + } async categories(workspaceId: string, storeId: string, q: string) { return this.db.workspaceTransaction(workspaceId, async (tx) => { const store = await this.store(tx, storeId); @@ -174,7 +194,7 @@ export class PricingService { }; }); } - /** 开发模式可复算草稿;保存确认时复读规则,正式模式仍只接受已审核版本。 */ + /** 保存确认时复读规则;草稿卡是否参与新报价由配置控制。 */ async rules(tx: Tx, input: PricingPreviewRequest, store: Store) { const card = ( await tx.query<{ id: string; version: string; status: string; updated_at: Date }>( @@ -190,7 +210,7 @@ export class PricingService { throw new ApiError( 422, 'PRICING_RULES_UNAVAILABLE', - '没有已审核且有效的物流费率,请先配置正式费率', + '没有可用的物流费率,请检查费率卡状态和草稿卡使用配置', ); const category = await tx.query( `SELECT id FROM atlas.platform_product_categories WHERE id=$1 AND platform_id=$2 AND is_active`, @@ -227,24 +247,9 @@ export class PricingService { [card.id, input.pricing.transport_method_ids, card.status], ) ).rows; - const mapping = ( - await tx.query<{ commission_category_id: string }>( - `SELECT * FROM atlas.platform_product_category_commission_mappings WHERE platform_id=$1 AND platform_product_category_id=$2 AND fulfillment=$3 AND market_code='RU' AND ${active} ORDER BY priority,effective_from DESC,id LIMIT 1 FOR SHARE`, - [ - store.platform_id, - input.platform_product_category_id, - input.fulfillment, - ], - ) - ).rows[0]; - let commissions: CommissionRule[] = mapping - ? ( - await tx.query( - `SELECT * FROM atlas.commission_category_rules WHERE commission_category_id=$1 AND fulfillment=$2 AND market_code='RU' AND currency_code IN ('CNY','RUB') AND ${active} ORDER BY id FOR SHARE`, - [mapping.commission_category_id, input.fulfillment], - ) - ).rows - : []; + const official = await readCommissionRules(tx, store.platform_id, input.platform_product_category_id, input.fulfillment); + const mapping = official.mapping; + let commissions = official.commissions; if ( !commissions.length && input.pricing.temporary_commission_rate !== null && @@ -266,7 +271,7 @@ export class PricingService { throw new ApiError( 422, 'PRICING_COMMISSION_UNAVAILABLE', - '该类目尚无有效佣金规则;允许临时佣金的方案可在详情中补充', + '该类目尚无有效佣金规则;请补充已核实的临时佣金率,并使用允许临时佣金的方案或本次自定义参数', ); if ( commissions.some((c) => c.id) && diff --git a/apps/api/src/product-editor/product-editor.models.ts b/apps/api/src/product-editor/product-editor.models.ts index 169ec25..9b8367f 100644 --- a/apps/api/src/product-editor/product-editor.models.ts +++ b/apps/api/src/product-editor/product-editor.models.ts @@ -17,6 +17,8 @@ export type ProductRow = { default_package_width_mm: string | null; default_package_height_mm: string | null; default_shipping_weight_g: string | null; + package_weight_mode: 'common' | 'variant'; + package_dimensions_mode: 'common' | 'variant'; physical_spec_status: 'missing' | 'ambiguous' | 'confirmed'; package_spec_status: 'missing' | 'ambiguous' | 'confirmed'; ai_source_material_overrides: unknown; diff --git a/apps/api/src/product-editor/product-editor.service.ts b/apps/api/src/product-editor/product-editor.service.ts index 69d4b09..4816465 100644 --- a/apps/api/src/product-editor/product-editor.service.ts +++ b/apps/api/src/product-editor/product-editor.service.ts @@ -140,9 +140,10 @@ export class ProductEditorService { ); const created = await transaction.query<{ id: string }>( `INSERT INTO atlas.product_platform_profiles - (workspace_id, product_id, platform_id, target_store_id, market_code) + (workspace_id, product_id, platform_id, target_store_id, market_code, fulfillment) SELECT current_setting('app.workspace_id')::uuid, $1, $2, $3, - nullif(p.capabilities->>'default_market_code', '') + nullif(p.capabilities->>'default_market_code', ''), + CASE WHEN upper(p.code) = 'OZON' THEN 'rfbs' ELSE 'unknown' END FROM atlas.platforms p WHERE p.id = $2 ON CONFLICT (workspace_id, product_id, platform_id) DO NOTHING RETURNING id`, @@ -434,6 +435,8 @@ export class ProductEditorService { }, default_physical_spec: defaultPhysicalSpec, default_package_spec: defaultPackageSpec, + package_weight_mode: product.package_weight_mode, + package_dimensions_mode: product.package_dimensions_mode, }, profile: { id: profile.id, @@ -822,6 +825,7 @@ export class ProductEditorService { default_package_length_mm = $11, default_package_width_mm = $12, default_package_height_mm = $13, default_shipping_weight_g = $14, physical_spec_status = $15, package_spec_status = $16, + package_weight_mode = $21, package_dimensions_mode = $22, ai_source_material_overrides = $17::jsonb, ai_source_material_base_revision = $18, revision = revision + 1, updated_at = now() @@ -847,6 +851,8 @@ export class ProductEditorService { input.ai_source_material.source_snapshot_revision, productId, expected.product, + input.common.package_weight_mode ?? product.package_weight_mode, + input.common.package_dimensions_mode ?? product.package_dimensions_mode, ], ); if (!updatedProduct.rows[0]) @@ -2301,10 +2307,11 @@ export class ProductEditorService { check( variant.package_spec.status, [ - variant.package_spec.length_mm, - variant.package_spec.width_mm, - variant.package_spec.height_mm, - variant.package_spec.shipping_weight_g, + variant.package_spec.length_mm ?? input.common.default_package_spec.length_mm, + variant.package_spec.width_mm ?? input.common.default_package_spec.width_mm, + variant.package_spec.height_mm ?? input.common.default_package_spec.height_mm, + variant.package_spec.shipping_weight_g ?? + input.common.default_package_spec.shipping_weight_g, ], `variants.${index}.package_spec`, ); @@ -2370,6 +2377,7 @@ export class ProductEditorService { p.default_package_length_mm::text, p.default_package_width_mm::text, p.default_package_height_mm::text, p.default_shipping_weight_g::text, p.physical_spec_status, p.package_spec_status, + p.package_weight_mode, p.package_dimensions_mode, p.ai_source_material_overrides, p.ai_source_material_base_revision, c.normalized_payload, c.source_snapshot_revision FROM atlas.products p JOIN atlas.collection_items c ON c.id = p.collection_item_id @@ -2395,6 +2403,7 @@ export class ProductEditorService { p.default_package_length_mm::text, p.default_package_width_mm::text, p.default_package_height_mm::text, p.default_shipping_weight_g::text, p.physical_spec_status, p.package_spec_status, + p.package_weight_mode, p.package_dimensions_mode, p.ai_source_material_overrides, p.ai_source_material_base_revision, c.normalized_payload, c.source_snapshot_revision FROM atlas.products p JOIN atlas.collection_items c ON c.id = p.collection_item_id diff --git a/apps/api/src/product-editor/product-editor.validation.ts b/apps/api/src/product-editor/product-editor.validation.ts index fb7960d..53fda0b 100644 --- a/apps/api/src/product-editor/product-editor.validation.ts +++ b/apps/api/src/product-editor/product-editor.validation.ts @@ -30,7 +30,6 @@ type VariantValidationRow = { offer_id: string | null; selling_price: string | null; pricing_valid: boolean; - pricing_development_only: boolean; package_length_mm: string | null; package_width_mm: string | null; package_height_mm: string | null; @@ -291,12 +290,6 @@ export class ProductEditorValidationOperations { field: `variants.${variant.id}.pricing_selection`, message: '请按当前采购价和包装确认报价', }); - if (variant.pricing_development_only) - issues.push({ - code: 'PRICING_RATE_REVIEW_REQUIRED', - field: `variants.${variant.id}.pricing_selection`, - message: '当前报价使用开发草稿费率;请在费率正式审核后重新试算确认', - }); if (!variant.selling_price) issues.push({ code: 'PRICE_REQUIRED', @@ -363,7 +356,6 @@ export class ProductEditorValidationOperations { const variants = await transaction.query( `SELECT v.id, v.display_name, listing.status AS listing_status, listing.offer_id, listing.selling_price::text, listing.pricing_valid, - bool_or(coalesce(quote.input_snapshot->'context'->>'development_only','false')='true') AS pricing_development_only, coalesce(v.package_length_mm, product.default_package_length_mm)::text AS package_length_mm, coalesce(v.package_width_mm, product.default_package_width_mm)::text AS package_width_mm, coalesce(v.package_height_mm, product.default_package_height_mm)::text AS package_height_mm, @@ -374,7 +366,6 @@ export class ProductEditorValidationOperations { JOIN atlas.products product ON product.id = v.product_id JOIN atlas.product_variant_platform_listings listing ON listing.product_variant_id = v.id AND listing.profile_id = $1 - LEFT JOIN atlas.pricing_quotes quote ON quote.id = listing.pricing_quote_id LEFT JOIN atlas.product_variant_listing_media media ON media.listing_id = listing.id WHERE v.product_id = $2 AND v.deleted_at IS NULL AND ($3::uuid[] IS NULL OR v.id = ANY($3::uuid[])) diff --git a/apps/api/test/commission-import.test.ts b/apps/api/test/commission-import.test.ts new file mode 100644 index 0000000..f29e3ad --- /dev/null +++ b/apps/api/test/commission-import.test.ts @@ -0,0 +1,466 @@ +import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest'; +import { randomUUID, randomInt } from 'node:crypto'; +import { loadEnvFile } from 'node:process'; +import { Client } from 'pg'; +import type { Database, DatabaseTransaction } from '@atlas/database'; +import type { + CommissionImportRequest, + CommissionImportPublish, +} from '@atlas/contracts'; +import { CommissionImportService } from '../src/pricing/commission-import.service.js'; +import { parseCommissionDocument } from '../src/pricing/commission-import-parser.js'; +import { IdempotencyService } from '../src/common/idempotency.service.js'; +import { PricingService } from '../src/pricing/pricing.service.js'; +import { fingerprint } from '../src/pricing/pricing-engine.js'; +import type { AppConfig } from '../src/config/app-config.module.js'; +loadEnvFile(new URL('../../../.env', import.meta.url)); +const makeDocument = ( + parent = '900000001', + child = '900000002', + rates = ['12.00', '14.00', '20.50'], +) => ({ + code: 1, + msg: '获取成功', + data: [ + { + id: 1, + label: '测试分组', + value: '测试分组', + cate_id: parent, + children: [ + { + id: '1.1', + label: '测试类目', + value: '测试类目', + cate_id: child, + children: rates.map((rate, i) => ({ + id: `1.1.${i + 1}`, + label: `${['售价 ≤ 1500₽', '1500₽ < 售价 ≤ 5000₽', '售价 > 5000₽'][i]} (${rate}%)`, + value: `${i + 1},${rate}`, + })), + }, + ], + }, + ], +}); +describe('佣金来源解析', () => { + it('保留三个精确区间并将百分数转换为比例', () => { + const c = parseCommissionDocument(makeDocument())[0]; + expect(c.bands.map((b) => b.commission_rate)).toEqual([ + '0.120000', + '0.140000', + '0.205000', + ]); + expect( + c.bands.map((b) => [ + b.min_price, + b.max_price, + b.min_inclusive, + b.max_inclusive, + ]), + ).toEqual([ + ['0', '1500', false, true], + ['1500', '5000', false, true], + ['5000', null, false, false], + ]); + }); + it('拒绝标签和值不一致、漏档、重复类目及超大文件', () => { + const d = makeDocument(); + d.data[0].children[0].children[0].value = '1,15.00'; + expect(() => parseCommissionDocument(d)).toThrow(); + expect(() => parseCommissionDocument(makeDocument('1', '1'))).toThrow(); + const missing = makeDocument(); + missing.data[0].children[0].children.pop(); + expect(() => parseCommissionDocument(missing)).toThrow(); + expect(() => + parseCommissionDocument({ x: 'a'.repeat(2 * 1024 * 1024) }), + ).toThrow(); + }); +}); + +// 所有业务数据在测试事务内,结束一律回滚;不会发布或替换用户的实际佣金版本。 +describe('佣金导入 PostgreSQL 集成', () => { + let client: Client, + db: Database, + service: CommissionImportService, + pricing: PricingService; + let user: string, + workspace: string, + platform: string, + store: string, + leaf: string, + oldId: string, + oldRule: string; + let request: CommissionImportRequest; + let savepoint = 0; + beforeEach(async () => { + const connection = new URL(process.env.MIGRATOR_DATABASE_URL!); + connection.username = process.env.POSTGRES_ADMIN_USER!; + connection.password = process.env.POSTGRES_ADMIN_PASSWORD!; + client = new Client({ connectionString: connection.toString() }); + await client.connect(); + await client.query('BEGIN'); + user = randomUUID(); + workspace = randomUUID(); + await client.query( + `INSERT INTO atlas.users(id,email,password_hash) VALUES($1,$2,'test-only')`, + [user, `${user}@example.test`], + ); + await client.query( + `INSERT INTO atlas.workspaces(id,name,slug) VALUES($1,'佣金回滚测试',$2)`, + [workspace, workspace], + ); + await client.query( + `INSERT INTO atlas.workspace_memberships(workspace_id,user_id,role) VALUES($1,$2,'owner')`, + [workspace, user], + ); + await client.query( + `INSERT INTO atlas.system_admin_grants(user_id) VALUES($1)`, + [user], + ); + await client.query('SET LOCAL ROLE atlas_app'); + await client.query(`SELECT set_config('app.workspace_id',$1,true)`, [ + workspace, + ]); + platform = ( + await client.query( + `SELECT id FROM atlas.platforms WHERE upper(code::text)='OZON'`, + ) + ).rows[0].id; + store = ( + await client.query( + `INSERT INTO atlas.stores(workspace_id,platform_id,name,platform_client_id,currency_code,status,credential_status) VALUES($1,$2,'佣金测试',$3,'RUB','active','valid') RETURNING id`, + [workspace, platform, randomUUID()], + ) + ).rows[0].id; + const p = String(randomInt(100000000, 999999990)), + c = String(Number(p) + 1); + const parent = ( + await client.query( + `INSERT INTO atlas.platform_product_categories(platform_id,external_category_id,external_type_id,name,path,is_active) VALUES($1,$2,'0','测试分组','测试分组',false) RETURNING id`, + [platform, p], + ) + ).rows[0].id; + const cat = ( + await client.query( + `INSERT INTO atlas.platform_product_categories(platform_id,parent_id,external_category_id,external_type_id,name,path,is_active) VALUES($1,$2,$3,'0','测试类目','测试分组 / 测试类目',false) RETURNING id`, + [platform, parent, c], + ) + ).rows[0].id; + leaf = ( + await client.query( + `INSERT INTO atlas.platform_product_categories(platform_id,parent_id,external_category_id,external_type_id,name,path) VALUES($1,$2,$3,'991','测试类型','测试类型') RETURNING id`, + [platform, cat, c], + ) + ).rows[0].id; + request = { + platform_id: platform, + version: `qa-${randomUUID()}`, + source_reference: '自动化测试,事务回滚', + captured_on: '2026-09-28', + document: makeDocument(p, c), + }; + const oldCategory = parseCommissionDocument( + makeDocument(p, c, ['10.00', '11.00', '12.00']), + )[0]; + oldCategory.mapped_types = 1; + await client.query( + `UPDATE atlas.commission_imports SET is_current=false WHERE platform_id=$1 AND is_current`, + [platform], + ); + oldId = ( + await client.query( + `INSERT INTO atlas.commission_imports(platform_id,version,source_reference,captured_on,document,content_hash,categories,summary,activated_by,activated_at) VALUES($1,$2,'旧测试版本','2026-09-27','{}',$3,$4,'{}',$5,now()-interval '1 day') RETURNING id`, + [ + platform, + `old-${randomUUID()}`, + fingerprint(randomUUID()), + JSON.stringify([oldCategory]), + user, + ], + ) + ).rows[0].id; + const cc = ( + await client.query( + `INSERT INTO atlas.commission_categories(platform_id,source_category_key,name,path,source,status) VALUES($1,$2,'测试类目',$3,'competitor_capture','active') RETURNING id`, + [platform, `maozierp:${c}`, `test-${c}`], + ) + ).rows[0].id; + oldRule = ( + await client.query( + `INSERT INTO atlas.commission_category_rules(commission_category_id,fulfillment,market_code,currency_code,commission_rate,source,version,status,effective_from,import_id) VALUES($1,'rfbs','RU','RUB',0.1,'competitor_capture','old','active',now()-interval '1 day',$2) RETURNING id`, + [cc, oldId], + ) + ).rows[0].id; + await client.query( + `INSERT INTO atlas.platform_product_category_commission_mappings(platform_id,platform_product_category_id,commission_category_id,fulfillment,source,status,effective_from,import_id) VALUES($1,$2,$3,'rfbs','competitor_capture','active',now()-interval '1 day',$4)`, + [platform, leaf, cc, oldId], + ); + const transaction = async ( + work: (tx: DatabaseTransaction) => Promise, + ) => { + const sp = `operation_${++savepoint}`; + await client.query(`SAVEPOINT ${sp}`); + try { + const result = await work(client); + await client.query(`RELEASE SAVEPOINT ${sp}`); + return result; + } catch (e) { + await client.query(`ROLLBACK TO SAVEPOINT ${sp}`); + throw e; + } + }; + db = { + transaction, + query: (sql: string, args: unknown[]) => + transaction((tx) => tx.query(sql, args)), + workspaceTransaction: ( + id: string, + work: (tx: DatabaseTransaction) => Promise, + ) => + transaction(async (tx) => { + await tx.query( + `SELECT set_config('app.workspace_id',$1,true)`, + [id], + ); + return work(tx); + }), + } as unknown as Database; + const config = { + IDEMPOTENCY_KEY_SECRET: 'commission-test-idempotency-secret', + pricingAllowDraftRates: false, + } as AppConfig; + const idem = new IdempotencyService(db, config); + service = new CommissionImportService(db, idem); + pricing = new PricingService(db, idem, config); + }); + afterEach(async () => { + if (client) { + await client.query('ROLLBACK'); + await client.end(); + } + }); + const publishInput = async ( + s: CommissionImportService, + r: CommissionImportRequest, + ): Promise => ({ + ...r, + preview_hash: (await s.preview(r)).preview_hash, + confirm_scope: true, + }); + it('预览差异,启用三档规则,按内部及外部类目查询;旧规则保留', async () => { + const p = await service.preview(request); + expect(p.summary.eligible).toBe(1); + expect(p.categories[0].change).toBe('changed'); + const r = await service.publish( + workspace, + user, + randomUUID(), + await publishInput(service, request), + ); + expect(r.status).toBe(201); + const rules = await pricing.commissionRules(workspace, store, leaf); + expect(rules.bands.map((b) => b.commission_rate)).toEqual([ + '0.120000', + '0.140000', + '0.205000', + ]); + expect( + ( + await pricing.commissionRules( + workspace, + store, + undefined, + p.categories[0].source_category_id, + ) + ).bands, + ).toEqual(rules.bands); + expect( + ( + await client.query( + `SELECT status,commission_rate FROM atlas.commission_category_rules WHERE id=$1`, + [oldRule], + ) + ).rows[0], + ).toEqual({ status: 'archived', commission_rate: '0.100000' }); + expect((await service.preview(request)).preview_hash).not.toBe( + p.preview_hash, + ); + }); + it('相同幂等请求只发布一次,旧预览不能再启用', async () => { + const input = await publishInput(service, request), + key = randomUUID(); + const a = await service.publish(workspace, user, key, input); + expect(await service.publish(workspace, user, key, input)).toEqual( + JSON.parse(JSON.stringify(a)), + ); + await expect( + service.publish(workspace, user, randomUUID(), input), + ).rejects.toMatchObject({ + response: { error: { code: 'COMMISSION_PREVIEW_STALE' } }, + }); + const fresh = await publishInput(service, request); + await expect( + service.publish(workspace, user, randomUUID(), fresh), + ).rejects.toMatchObject({ + response: { error: { code: 'COMMISSION_IMPORT_EXISTS' } }, + }); + }); + it('0% 佣金可以启用并用于类目查询', async () => { + const d = request.document as ReturnType; + d.data[0].children[0].children = makeDocument('1', '2', [ + '0.00', + '0.00', + '0.00', + ]).data[0].children[0].children; + const p = await service.preview(request); + expect(p.summary).toMatchObject({ + eligible: 1, + excluded: 0, + mapped_types: 1, + }); + await service.publish( + workspace, + user, + randomUUID(), + await publishInput(service, request), + ); + expect( + (await pricing.commissionRules(workspace, store, leaf)).bands.map( + (b) => b.commission_rate, + ), + ).toEqual(['0.000000', '0.000000', '0.000000']); + }); + it('错误父类目被提示;无可映射规则时拒绝空版本', async () => { + const d = request.document as ReturnType; + d.data[0].cate_id = '123'; + expect( + (await service.preview(request)).categories[0].issues.join(), + ).toContain('父类目'); + await expect( + service.publish( + workspace, + user, + randomUUID(), + await publishInput(service, request), + ), + ).rejects.toMatchObject({ + response: { error: { code: 'COMMISSION_IMPORT_INVALID' } }, + }); + }); + it('目录变化后必须重新预览', async () => { + const input = await publishInput(service, request); + await client.query( + `UPDATE atlas.platform_product_categories SET is_active=false WHERE id=$1`, + [leaf], + ); + await expect( + service.publish(workspace, user, randomUUID(), input), + ).rejects.toMatchObject({ + response: { error: { code: 'COMMISSION_PREVIEW_STALE' } }, + }); + }); + it('撤销管理员授权阻止发布,其他工作空间不能读取门店规则', async () => { + const input = await publishInput(service, request); + await client.query('RESET ROLE'); + await client.query( + `UPDATE atlas.system_admin_grants SET revoked_at=now() WHERE user_id=$1`, + [user], + ); + await client.query('SET LOCAL ROLE atlas_app'); + await expect( + service.publish(workspace, user, randomUUID(), input), + ).rejects.toMatchObject({ + response: { error: { code: 'SYSTEM_ADMIN_REQUIRED' } }, + }); + await expect( + pricing.commissionRules(randomUUID(), store, leaf), + ).rejects.toMatchObject({ + response: { error: { code: 'STORE_NOT_FOUND' } }, + }); + }); + it('有目录异常时无需额外确认,仍可启用其他可用类目', async () => { + const d = request.document as ReturnType; + d.data[0].children.push( + makeDocument('1', '899999999').data[0].children[0], + ); + const preview = await service.preview(request); + expect(preview.summary).toMatchObject({ eligible: 1, excluded: 1 }); + const input = await publishInput(service, request); + await service.publish(workspace, user, randomUUID(), input); + expect( + (await pricing.commissionRules(workspace, store, leaf)).bands, + ).toHaveLength(3); + expect( + ( + await service.detail((await service.list(platform))[0].id) + ).categories.filter((c) => c.status === 'excluded'), + ).toHaveLength(1); + }); + it('后续写入失败时旧版本保持有效', async () => { + const input = await publishInput(service, request); + const original = client.query.bind(client); + const spy = vi.spyOn(client, 'query').mockImplementation((( + sql: string, + args: unknown[], + ) => { + if ( + typeof sql === 'string' && + sql.includes( + 'INSERT INTO atlas.platform_product_category_commission_mappings', + ) + ) + throw new Error('模拟映射写入失败'); + return original(sql, args); + }) as typeof client.query); + try { + await expect( + service.publish(workspace, user, randomUUID(), input), + ).rejects.toThrow('模拟映射写入失败'); + } finally { + spy.mockRestore(); + } + expect( + ( + await client.query( + 'SELECT is_current FROM atlas.commission_imports WHERE id=$1', + [oldId], + ) + ).rows[0].is_current, + ).toBe(true); + expect( + ( + await client.query( + 'SELECT status FROM atlas.commission_category_rules WHERE id=$1', + [oldRule], + ) + ).rows[0].status, + ).toBe('active'); + expect( + ( + await client.query( + 'SELECT id FROM atlas.commission_imports WHERE version=$1', + [request.version], + ) + ).rowCount, + ).toBe(0); + }); + it('数据库拒绝直接改写已启用的历史费率', async () => { + await expect( + db.transaction((tx) => + tx.query( + `UPDATE atlas.commission_category_rules SET commission_rate=0.9 WHERE id=$1`, + [oldRule], + ), + ), + ).rejects.toMatchObject({ code: '23514' }); + expect( + ( + await client.query( + `SELECT commission_rate FROM atlas.commission_category_rules WHERE id=$1`, + [oldRule], + ) + ).rows[0].commission_rate, + ).toBe('0.100000'); + }); +}); diff --git a/apps/api/test/pricing-draft-mode.unit.test.ts b/apps/api/test/pricing-draft-mode.unit.test.ts index fea95d9..8c09d03 100644 --- a/apps/api/test/pricing-draft-mode.unit.test.ts +++ b/apps/api/test/pricing-draft-mode.unit.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'vitest'; import { draftPricingEnabled } from '../src/config/app-config.module.js'; -describe('草稿费率开发模式边界', () => { +describe('草稿费率配置', () => { it('开发环境默认开放,可显式关闭;测试环境需显式开启', () => { assert.equal(draftPricingEnabled('development', 'auto'), true); assert.equal(draftPricingEnabled('development', 'false'), false); @@ -10,8 +10,9 @@ describe('草稿费率开发模式边界', () => { assert.equal(draftPricingEnabled('test', 'true'), true); }); - it('生产环境即使误设开关也不能使用草稿费率', () => { - assert.equal(draftPricingEnabled('production', 'true'), false); - assert.equal(draftPricingEnabled('production', 'auto'), false); + it('生产环境默认允许草稿卡,可显式关闭', () => { + assert.equal(draftPricingEnabled('production', 'true'), true); + assert.equal(draftPricingEnabled('production', 'auto'), true); + assert.equal(draftPricingEnabled('production', 'false'), false); }); }); diff --git a/apps/api/test/pricing-engine.unit.test.ts b/apps/api/test/pricing-engine.unit.test.ts index 4d0ad21..41a2182 100644 --- a/apps/api/test/pricing-engine.unit.test.ts +++ b/apps/api/test/pricing-engine.unit.test.ts @@ -99,6 +99,17 @@ describe('十进制定价引擎', () => { expect(o.can_confirm).toBe(false); expect(Number(o.profit_cny)).toBeLessThan(0); }); + it('零佣金仍生成正式报价,平台佣金费用为零', () => { + const o = calculatePricing( + input(), + 'RUB', + '12.3', + [rule], + [{ ...commission, commission_rate: '0.000000' }], + ).options[0]; + expect(o.commission_rate).toBe('0.000000'); + expect(o.fees.commission_cny).toBe('0.0000'); + }); it('实际重、尺寸和开区间边界精确生效', () => { const i = input(); i.package.shipping_weight_g = '500'; @@ -241,3 +252,74 @@ describe('十进制定价引擎', () => { expect(option.can_confirm).toBe(false); }); }); + +describe('导入佣金的卢布分档边界', () => { + const bands: CommissionRule[] = [ + { + ...commission, + min_price: '0', + max_price: '1500', + min_inclusive: false, + commission_rate: '0.12', + }, + { + ...commission, + min_price: '1500', + max_price: '5000', + min_inclusive: false, + commission_rate: '0.14', + }, + { + ...commission, + min_price: '5000', + max_price: null, + min_inclusive: false, + commission_rate: '0.205', + }, + ]; + it.each([ + ['1500', '0.12'], + ['1500.01', '0.14'], + ['5000', '0.14'], + ['5000.01', '0.205'], + ])('售价 %s 只命中对应佣金档', (price, rate) => { + const i = input(); + i.pricing.mode = 'fixed_sale'; + i.pricing.selling_price = price; + const result = calculatePricing( + i, + 'RUB', + '12.3', + [{ ...rule, max_merchandise_value: null }], + bands, + ); + expect(result.options).toHaveLength(1); + expect(Number(result.options[0].commission_rate)).toBe(Number(rate)); + }); + it('人民币售价换算卢布后匹配费率', () => { + const i = input(); + i.pricing.mode = 'fixed_sale'; + i.pricing.selling_price = '150'; + const result = calculatePricing( + i, + 'CNY', + '10', + [{ ...rule, max_merchandise_value: null }], + bands, + ); + expect(result.options).toHaveLength(1); + expect(Number(result.options[0].commission_rate)).toBe(0.12); + i.pricing.selling_price = '150.01'; + expect( + Number( + calculatePricing( + i, + 'CNY', + '10', + [{ ...rule, max_merchandise_value: null }], + bands, + ).options[0].commission_rate, + ), + ).toBe(0.14); + }); +}); diff --git a/apps/api/test/pricing.e2e.test.ts b/apps/api/test/pricing.e2e.test.ts index 59f42bc..ecc9696 100644 --- a/apps/api/test/pricing.e2e.test.ts +++ b/apps/api/test/pricing.e2e.test.ts @@ -577,11 +577,13 @@ describe('SKU 试算与原子确认 HTTP / PostgreSQL', () => { ); const input = quote(); input.pricing.temporary_commission_rate = null; - assert.equal( - (await request('/pricing/quote-previews', 'POST', input)).error - .code, - 'PRICING_COMMISSION_UNAVAILABLE', + const withoutCommission = await request( + '/pricing/quote-previews', + 'POST', + input, ); + assert.equal(withoutCommission.error.code, 'PRICING_COMMISSION_UNAVAILABLE'); + assert.match(withoutCommission.error.message, /本次自定义参数/); input.pricing.temporary_commission_rate = '0.12'; input.pricing.mode = 'fixed_sale'; input.pricing.selling_price = '10'; @@ -598,7 +600,7 @@ describe('SKU 试算与原子确认 HTTP / PostgreSQL', () => { }; assert.equal((await save(state, payload)).status, 409); }); - it('开发草稿可试算并确认到商品编辑页,但不能通过首次上架校验', async () => { + it('草稿费率可试算、确认且不阻断首次上架预检', async () => { await db.query( `UPDATE atlas.logistics_rate_cards SET status='draft',reviewed_at=NULL,reviewed_by_user_id=NULL WHERE id=$1`, [card], @@ -637,11 +639,13 @@ describe('SKU 试算与原子确认 HTTP / PostgreSQL', () => { { 'X-Atlas-Platform-Id': platform }, ); assert.equal(validation.status, 200, JSON.stringify(validation)); - assert.ok( + assert.equal( validation.data.variants[0].issues.some( (issue: { code: string }) => - issue.code === 'PRICING_RATE_REVIEW_REQUIRED', + issue.code === 'PRICING_RATE_REVIEW_REQUIRED' || + issue.code === 'PRICING_REQUIRED', ), + false, ); const rateCard = await db.query<{ status: string; reviewed_at: Date | null }>( 'SELECT status,reviewed_at FROM atlas.logistics_rate_cards WHERE id=$1', diff --git a/apps/api/test/product-editor.e2e.test.ts b/apps/api/test/product-editor.e2e.test.ts index c04e701..81e527c 100644 --- a/apps/api/test/product-editor.e2e.test.ts +++ b/apps/api/test/product-editor.e2e.test.ts @@ -276,6 +276,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => { assert.equal(editor.response.status, 200); assert.equal(editor.body.data.product.id, first.productId); assert.equal(editor.body.data.profile.id, first.profileId); + assert.equal(editor.body.data.profile.fulfillment, 'rfbs'); assert.equal(editor.body.data.variants.length, 1); assert.equal(editor.body.data.variants[0].listing.status, 'not_listed'); }); @@ -2189,4 +2190,129 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => { ), ); }); + + it('包装重量模式独立保存,并允许 SKU 已确认重量继承通用尺寸', async () => { + await database.workspaceTransaction(workspaceId, async (tx) => { + const store = await tx.query<{ id: string }>( + `SELECT id FROM atlas.stores + WHERE workspace_id = current_setting('app.workspace_id')::uuid + AND platform_id = $1 AND is_default = true LIMIT 1`, + [platformId], + ); + if (!store.rows.length) + await tx.query( + `INSERT INTO atlas.stores + (workspace_id, platform_id, name, platform_client_id, + currency_code, is_default, status, credential_status) + VALUES (current_setting('app.workspace_id')::uuid, $1, + '包装规格模式测试店铺', $2, 'RUB', true, 'active', 'valid')`, + [platformId, `editor-package-${randomUUID()}`], + ); + }); + const created = await createCollectionItem('包装规格模式测试商品'); + const entered = await request( + `/api/collection-items/${created.collectionItemId}/editor-contexts`, + { + method: 'POST', + headers: headers({ + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': randomUUID(), + }), + }, + ); + assert.equal(entered.response.status, 201); + const before = await request(`/api/products/${created.productId}/editor`, { + headers: headers({ 'X-Atlas-Platform-Id': platformId }), + }); + assert.equal(before.response.status, 200); + const snapshot = before.body.data; + const variant = snapshot.variants[0]; + const payload = { + ai_source_material: snapshot.source_material, + ai_source_overridden_fields: snapshot.source_material_state.overridden_fields, + common: { + ...snapshot.product.common, + default_physical_spec: snapshot.product.default_physical_spec, + default_package_spec: { + length_mm: '120', + width_mm: '60', + height_mm: '30', + shipping_weight_g: null, + status: 'missing', + }, + package_weight_mode: 'variant', + package_dimensions_mode: 'common', + }, + profile: { + target_store_id: snapshot.profile.target_store.id, + fulfillment: 'rfbs', + title: snapshot.profile.title, + description: snapshot.profile.description, + tags: snapshot.profile.tags, + platform_product_category_id: snapshot.profile.category.id, + category_source: snapshot.profile.category.source, + }, + variants: [ + { + id: variant.id, + client_key: null, + display_name: variant.display_name, + source_sku: variant.source_sku, + specification_values: variant.specification_values, + purchase_cost_cny: variant.purchase_cost_cny, + title: variant.title, + tags: variant.tags, + description: variant.description, + physical_spec: variant.physical_spec, + package_spec: { + length_mm: null, + width_mm: null, + height_mm: null, + shipping_weight_g: '400', + status: 'confirmed', + }, + offer_id: variant.listing.offer_id, + media_asset_ids: variant.media.map((item: { asset_id: string }) => item.asset_id), + }, + ], + }; + const saved = await request(`/api/products/${created.productId}/editor-draft`, { + method: 'PUT', + headers: headers({ + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': randomUUID(), + 'If-Match': `"${snapshot.product.revision}:${snapshot.profile.revision}"`, + }), + body: JSON.stringify(payload), + }); + assert.equal(saved.response.status, 200, JSON.stringify(saved.body)); + const after = await request(`/api/products/${created.productId}/editor`, { + headers: headers({ 'X-Atlas-Platform-Id': platformId }), + }); + assert.equal(after.body.data.product.package_weight_mode, 'variant'); + assert.equal(after.body.data.product.package_dimensions_mode, 'common'); + assert.equal(after.body.data.product.default_package_spec.shipping_weight_g, null); + assert.equal(after.body.data.variants[0].package_spec.shipping_weight_g, '400.000'); + assert.equal(after.body.data.variants[0].effective_package_spec.length_mm, '120.000'); + assert.equal(after.body.data.variants[0].effective_package_spec.status, 'confirmed'); + const { + package_weight_mode: _weightMode, + package_dimensions_mode: _dimensionsMode, + ...legacyCommon + } = payload.common; + const legacySave = await request(`/api/products/${created.productId}/editor-draft`, { + method: 'PUT', + headers: headers({ + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': randomUUID(), + 'If-Match': `"${after.body.data.product.revision}:${after.body.data.profile.revision}"`, + }), + body: JSON.stringify({ ...payload, common: legacyCommon }), + }); + assert.equal(legacySave.response.status, 200, JSON.stringify(legacySave.body)); + const unchangedMode = await request(`/api/products/${created.productId}/editor`, { + headers: headers({ 'X-Atlas-Platform-Id': platformId }), + }); + assert.equal(unchangedMode.body.data.product.package_weight_mode, 'variant'); + }); }); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 7b3e5b9..182bdb0 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -5,6 +5,10 @@ import { router } from './router'; import { SessionProvider } from '@/services/session'; import './App.css'; +// 控件圆角与 SKU 编辑表单一致;页面卡片仍使用原有的基础圆角。 +const controlCorners = { borderRadius: 7, borderRadiusSM: 7, borderRadiusLG: 7 }; +const fieldStyle = { ...controlCorners, controlHeight: 35 }; + function App() { return ( diff --git a/apps/web/src/components/PackageIntegerInput.tsx b/apps/web/src/components/PackageIntegerInput.tsx new file mode 100644 index 0000000..d127391 --- /dev/null +++ b/apps/web/src/components/PackageIntegerInput.tsx @@ -0,0 +1,28 @@ +import { Input } from 'antd'; +import type { InputProps } from 'antd'; + +const digitsOnly = /^\d+$/; + +export function isPositivePackageInteger(value: unknown): value is string { + return typeof value === 'string' && digitsOnly.test(value) && Number(value) > 0; +} + +export const packageIntegerRule = { + validator: (_: unknown, value: unknown) => + value === null || value === undefined || value === '' || isPositivePackageInteger(value) + ? Promise.resolve() + : Promise.reject(new Error('请输入大于 0 的整数')), +}; + +/** 保留旧小数原值供人工核对,新输入仅接收数字,避免自动取整造成计费数据变化。 */ +export function PackageIntegerInput({ onChange, ...props }: InputProps) { + return ( + { + if (/^\d*$/.test(event.target.value)) onChange?.(event); + }} + /> + ); +} diff --git a/apps/web/src/components/pricing/PricingCalculator.tsx b/apps/web/src/components/pricing/PricingCalculator.tsx index 7c4574e..36f5ef2 100644 --- a/apps/web/src/components/pricing/PricingCalculator.tsx +++ b/apps/web/src/components/pricing/PricingCalculator.tsx @@ -29,8 +29,23 @@ import type { } from '@atlas/contracts'; import { pricingPreviewRequestSchema } from '@atlas/contracts'; import { pricingApi } from '@/services/pricing'; -import { apiErrorMessage } from '@/services/api'; +import { apiErrorCode, apiErrorMessage } from '@/services/api'; +import { PackageIntegerInput } from '@/components/PackageIntegerInput'; import { percentToRate, rateToPercent } from './pricingPercentage'; +import { pricingInputProblem } from './pricingInputValidation'; + +function detachPolicy(p: PricingParameters): PricingParameters { + return { + ...p, + pricing_policy_id: null, + fx: + p.fx.mode === 'policy_fixed' + ? p.fx.rate + ? { mode: 'one_time_override', rate: p.fx.rate, reason: '沿用原方案固定汇率' } + : { mode: 'system' } + : p.fx, + }; +} export function defaultPricing(catalog: PricingCatalog): PricingParameters | null { const policy = catalog.policies.find( @@ -63,13 +78,19 @@ export function PricingParametersFields({ onChange, catalog, policyEditing = false, + expandFees = false, }: { policyEditing?: boolean; + expandFees?: boolean; value: PricingParameters; onChange: (p: PricingParameters) => void; catalog: PricingCatalog; }) { const patch = (v: Partial) => onChange({ ...p, ...v }); + const [feesOpen, setFeesOpen] = useState(false); + useEffect(() => { + if (expandFees) setFeesOpen(true); + }, [expandFees]); const rates: Array<[keyof PricingParameters, string]> = [ ['desired_markup_rate', '目标加价率'], ['list_price_discount_rate', '划线折扣率'], @@ -100,7 +121,7 @@ export function PricingParametersFields({ mode: p.mode, selling_price: p.selling_price, }); - else patch({ pricing_policy_id: null }); + else onChange(detachPolicy(p)); }} /> @@ -112,13 +133,13 @@ export function PricingParametersFields({ value={p.rate_card_id} options={catalog.rate_cards.map((x) => ({ value: x.id, - label: `${x.name} · ${x.version}${x.development_only ? ' · 开发草稿' : ''}`, + label: `${x.name} · ${x.version}${x.development_only ? ' · 待审核费率' : ''}`, }))} onChange={(id) => { const c = catalog.rate_cards.find((x) => x.id === id)!; - patch({ + onChange({ + ...detachPolicy(p), rate_card_id: id, - pricing_policy_id: null, transport_method_ids: c.transports.map((t) => t.id), default_transport_method_id: c.transports[0]?.id ?? null, }); @@ -148,6 +169,8 @@ export function PricingParametersFields({ 目标加价率 50% 表示按采购成本增加 50% 的目标利润;预估利润以已配置费用为准。 setFeesOpen(keys.includes('fees'))} items={[ { key: 'fees', @@ -230,10 +253,35 @@ export function PricingParametersFields({ step="1" precision={2} addonAfter="%" - value={p.temporary_commission_rate === null ? null : rateToPercent(p.temporary_commission_rate)} - onChange={(v) => patch({ temporary_commission_rate: v === null ? null : percentToRate(v) })} + disabled={policyEditing} + value={ + p.temporary_commission_rate === null + ? null + : rateToPercent(p.temporary_commission_rate) + } + onChange={(v) => { + const rate = v === null ? null : percentToRate(v); + const selected = catalog.policies.find( + (policy) => policy.id === p.pricing_policy_id + ); + if ( + rate !== null && + selected && + !selected.allow_commission_override + ) + onChange({ + ...detachPolicy(p), + temporary_commission_rate: rate, + }); + else patch({ temporary_commission_rate: rate }); + }} /> + {!policyEditing && ( + + 仅在缺少正式佣金规则时使用。方案不允许临时佣金时,填写后改为本次自定义参数;请依据已核实的类目费率输入。 + + )} ), }, @@ -282,6 +330,7 @@ export function PricingCalculator({ const [preview, setPreview] = useState(null); const [selected, setSelected] = useState(null); const [error, setError] = useState(''); + const [commissionError, setCommissionError] = useState<'missing' | 'forbidden' | null>(null); const [busy, setBusy] = useState(false); const sequence = useRef(0); const [refresh, setRefresh] = useState(0); @@ -295,10 +344,16 @@ export function PricingCalculator({ controller = new AbortController(); setPreview(null); setError(''); + setCommissionError(null); setBusy(false); + const problem = pricingInputProblem(input); + if (problem) { + setError(problem); + return () => controller.abort(); + } const parsed = pricingPreviewRequestSchema.safeParse(input); if (!parsed.success) { - setError(parsed.error.issues.map((i) => i.message).join(';')); + setError('请检查定价方案、运输方式和汇率参数'); return () => controller.abort(); } const timer = setTimeout(() => { @@ -309,11 +364,24 @@ export function PricingCalculator({ if (sequence.current === seq) { setPreview(p); setSelected(p.recommended_option_id); + setCommissionError(null); } }) .catch((e) => { - if (sequence.current === seq && !controller.signal.aborted) - setError(apiErrorMessage(e)); + if (sequence.current === seq && !controller.signal.aborted) { + const code = apiErrorCode(e); + if (code === 'PRICING_COMMISSION_UNAVAILABLE') { + setCommissionError('missing'); + setError( + input.pricing.temporary_commission_rate === null + ? '该类目缺少有效佣金规则,请填写已核实的临时佣金率' + : '当前方案不允许使用临时佣金,请改用本次自定义参数或允许覆盖的方案' + ); + } else if (code === 'PRICING_COMMISSION_OVERRIDE_FORBIDDEN') { + setCommissionError('forbidden'); + setError('该类目已有正式佣金规则,请清空临时佣金率'); + } else setError(apiErrorMessage(e)); + } }) .finally(() => { if (sequence.current === seq) setBusy(false); @@ -340,9 +408,10 @@ export function PricingCalculator({ const saved = await pricingApi.createPolicy(input.store_id, { name: policyName ?? '', note: null, - allow_commission_override: !!localCatalog.policies.find( - (x) => x.id === p.pricing_policy_id - )?.allow_commission_override, + allow_commission_override: + p.temporary_commission_rate !== null || + !!localCatalog.policies.find((x) => x.id === p.pricing_policy_id) + ?.allow_commission_override, parameters: { ...p, pricing_policy_id: null, @@ -437,16 +506,17 @@ export function PricingCalculator({