From 84b230e69ad8252f49470853a502f43f98966627 Mon Sep 17 00:00:00 2001 From: R524809 Date: Mon, 28 Sep 2026 17:34:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(pricing):=20=E5=BC=80=E5=8F=91=E5=AE=9A?= =?UTF-8?q?=E4=BB=B7=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 5 +- apps/api/package.json | 3 + apps/api/src/app.module.ts | 2 + apps/api/src/config/app-config.module.ts | 19 +- .../src/pricing/pricing-admin.controller.ts | 82 ++ apps/api/src/pricing/pricing-admin.service.ts | 352 ++++++++ apps/api/src/pricing/pricing-engine.ts | 302 +++++++ apps/api/src/pricing/pricing.controller.ts | 190 ++++ apps/api/src/pricing/pricing.module.ts | 18 + apps/api/src/pricing/pricing.service.ts | 850 ++++++++++++++++++ .../product-editor/product-editor.service.ts | 16 +- .../product-editor.validation.ts | 20 +- apps/api/test/pricing-admin.e2e.test.ts | 294 ++++++ apps/api/test/pricing-draft-mode.unit.test.ts | 17 + apps/api/test/pricing-engine.unit.test.ts | 243 +++++ apps/api/test/pricing.e2e.test.ts | 653 ++++++++++++++ ...roduct-editor-processed-media.unit.test.ts | 1 + .../components/pricing/PricingCalculator.tsx | 676 ++++++++++++++ .../web/src/components/pricing/SkuPricing.tsx | 447 +++++++++ .../components/pricing/pricingPercentage.ts | 25 + apps/web/src/layouts/SystemAdminLayout.tsx | 65 +- apps/web/src/pages/pricing/PricingPage.tsx | 459 +++++++++- .../src/pages/products/ProductEditPage.tsx | 20 +- .../components/ProductVariantSection.tsx | 52 +- .../pages/system/SystemPricingRatesPage.tsx | 398 ++++++++ apps/web/src/router/index.tsx | 8 +- apps/web/src/services/pricing.ts | 27 + apps/web/src/services/pricingAdmin.ts | 13 + apps/web/vite.config.ts | 4 + apps/worker/src/exchange-rate-sync.ts | 68 ++ apps/worker/src/main.ts | 11 + apps/worker/test/exchange-rate-sync.test.mjs | 17 + docs/api/pricing.md | 231 ++--- docs/api/product-editor-and-publishing.md | 21 +- docs/product/sku-pricing-workflow-proposal.md | 103 +++ docs/project/HANDOFF.md | 25 +- ...n-fulfillment-and-payment-fees-research.md | 67 ++ .../implementation-readiness-open-items.md | 2 + docs/reviews/sku-pricing-verification.md | 62 ++ packages/contracts/src/index.ts | 3 + packages/contracts/src/pricing-admin.ts | 81 ++ packages/contracts/src/pricing.ts | 273 ++++++ packages/contracts/src/product-editor.ts | 19 +- .../database/migrations/043_sku_pricing.sql | 51 ++ .../migrations/044_pricing_integrity.sql | 26 + .../045_pricing_snapshot_guards.sql | 18 + .../046_profile_pricing_invalidation.sql | 9 + .../migrations/047_cel_draft_rate_card.sql | 37 + .../048_store_pricing_invalidation.sql | 10 + .../migrations/049_logistics_rate_reviews.sql | 20 + .../050_reviewed_logistics_immutable.sql | 37 + .../051_reviewed_logistics_scope_guard.sql | 31 + pnpm-lock.yaml | 14 + turbo.json | 3 +- 54 files changed, 6264 insertions(+), 236 deletions(-) create mode 100644 apps/api/src/pricing/pricing-admin.controller.ts create mode 100644 apps/api/src/pricing/pricing-admin.service.ts create mode 100644 apps/api/src/pricing/pricing-engine.ts create mode 100644 apps/api/src/pricing/pricing.controller.ts create mode 100644 apps/api/src/pricing/pricing.module.ts create mode 100644 apps/api/src/pricing/pricing.service.ts create mode 100644 apps/api/test/pricing-admin.e2e.test.ts create mode 100644 apps/api/test/pricing-draft-mode.unit.test.ts create mode 100644 apps/api/test/pricing-engine.unit.test.ts create mode 100644 apps/api/test/pricing.e2e.test.ts create mode 100644 apps/web/src/components/pricing/PricingCalculator.tsx create mode 100644 apps/web/src/components/pricing/SkuPricing.tsx create mode 100644 apps/web/src/components/pricing/pricingPercentage.ts create mode 100644 apps/web/src/pages/system/SystemPricingRatesPage.tsx create mode 100644 apps/web/src/services/pricing.ts create mode 100644 apps/web/src/services/pricingAdmin.ts create mode 100644 apps/worker/src/exchange-rate-sync.ts create mode 100644 apps/worker/test/exchange-rate-sync.test.mjs create mode 100644 docs/product/sku-pricing-workflow-proposal.md create mode 100644 docs/requirements/ozon-fulfillment-and-payment-fees-research.md create mode 100644 docs/reviews/sku-pricing-verification.md create mode 100644 packages/contracts/src/pricing-admin.ts create mode 100644 packages/contracts/src/pricing.ts create mode 100644 packages/database/migrations/043_sku_pricing.sql create mode 100644 packages/database/migrations/044_pricing_integrity.sql create mode 100644 packages/database/migrations/045_pricing_snapshot_guards.sql create mode 100644 packages/database/migrations/046_profile_pricing_invalidation.sql create mode 100644 packages/database/migrations/047_cel_draft_rate_card.sql create mode 100644 packages/database/migrations/048_store_pricing_invalidation.sql create mode 100644 packages/database/migrations/049_logistics_rate_reviews.sql create mode 100644 packages/database/migrations/050_reviewed_logistics_immutable.sql create mode 100644 packages/database/migrations/051_reviewed_logistics_scope_guard.sql diff --git a/.env.example b/.env.example index 022fe9f..c481472 100644 --- a/.env.example +++ b/.env.example @@ -87,8 +87,9 @@ EXTENSION_PORT=8110 # DeepSeek API Key DEEPSEEK_API_KEY= +# DeepSeek Model Name +DEEPSEEK_MODEL_NAME=deepseek-flash + # 单张 AI 图片编辑使用 Pcore 的 gpt-image-2 异步图片接口;根地址和 /v1 地址均可,仅 Worker 读取。 PCORE_BASE_URL=https://pcore.ai/v1 PCORE_API_KEY= -# DeepSeek Model Name -DEEPSEEK_MODEL_NAME=deepseek-flash diff --git a/apps/api/package.json b/apps/api/package.json index e9d380b..ce66598 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -20,6 +20,7 @@ "@nestjs/core": "12.0.1", "@nestjs/platform-express": "12.0.1", "@nestjs/swagger": "12.0.1", + "decimal.js": "10.6.0", "helmet": "8.3.0", "ioredis": "6.0.0", "reflect-metadata": "0.2.2", @@ -30,6 +31,8 @@ "@nestjs/testing": "12.0.1", "@types/express": "5.0.6", "@types/node": "26.3.0", + "@types/pg": "8.23.1", + "pg": "8.23.0", "tsx": "4.23.13", "typescript": "7.0.2", "vitest": "^4.1.11" diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 899b02f..72c07b4 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,3 +1,4 @@ +import { PricingModule } from './pricing/pricing.module.js'; import { Controller, Get, Inject, Module } from '@nestjs/common'; import type { Database } from '@atlas/database'; import { AuthModule } from './auth/auth.module.js'; @@ -55,6 +56,7 @@ class HealthController { ProductEditorModule, PlatformCatalogAdminModule, OnlineProductsModule, + PricingModule, ], controllers: [HealthController], }) diff --git a/apps/api/src/config/app-config.module.ts b/apps/api/src/config/app-config.module.ts index 745a933..4ce2891 100644 --- a/apps/api/src/config/app-config.module.ts +++ b/apps/api/src/config/app-config.module.ts @@ -24,6 +24,7 @@ const environmentSchema = z.object({ NODE_ENV: z .enum(['development', 'test', 'production']) .default('development'), + PRICING_ALLOW_DRAFT_RATES: z.enum(['true', 'false', 'auto']).default('auto'), API_PORT: z.coerce.number().int().min(1).max(65_535).default(8200), API_PUBLIC_ORIGIN: z.url().default('http://localhost:8200'), WEB_ORIGIN: z.url().default('http://localhost:8100'), @@ -96,15 +97,26 @@ const environmentSchema = z.object({ }); type Environment = z.infer; +export function draftPricingEnabled( + nodeEnvironment: Environment['NODE_ENV'], + setting: Environment['PRICING_ALLOW_DRAFT_RATES'], +): boolean { + return ( + nodeEnvironment !== 'production' && + (setting === 'true' || + (nodeEnvironment === 'development' && setting === 'auto')) + ); +} export type AppConfig = Omit< Environment, - 'TRUSTED_PROXY_CIDRS' | 'SWAGGER_ENABLED' + 'TRUSTED_PROXY_CIDRS' | 'SWAGGER_ENABLED' | 'PRICING_ALLOW_DRAFT_RATES' > & { trustedProxyCidrs: string[]; swaggerEnabled: boolean; objectStorageAccessKey: string; objectStorageSecretKey: string; objectStorageForcePathStyle: boolean; + pricingAllowDraftRates: boolean; }; function validateEnvironment( @@ -140,6 +152,11 @@ 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/pricing/pricing-admin.controller.ts b/apps/api/src/pricing/pricing-admin.controller.ts new file mode 100644 index 0000000..7f07a77 --- /dev/null +++ b/apps/api/src/pricing/pricing-admin.controller.ts @@ -0,0 +1,82 @@ +import { + Body, + Controller, + Get, + Headers, + Inject, + Param, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { pricingRateReviewRequestSchema } from '@atlas/contracts'; +import { z } from 'zod'; +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 { PricingAdminService } from './pricing-admin.service.js'; + +function uuid(value: string, field: string): string { + if (!z.uuid().safeParse(value).success) + throw new ApiError(400, 'VALIDATION_ERROR', `${field} 无效`); + return value; +} + +@ApiTags('System pricing') +@ApiBearerAuth('access-token') +@UseGuards(SystemAdminGuard) +@Controller('system/pricing/rate-cards') +export class PricingAdminController { + constructor( + @Inject(PricingAdminService) + private readonly service: PricingAdminService, + ) {} + + @Get() + async list(@Query('platform_id') platformId: string) { + return { + data: await this.service.list(uuid(platformId, 'platform_id')), + }; + } + + @Get(':id') + async detail(@Param('id') id: string) { + return { data: await this.service.detail(uuid(id, 'rate_card_id')) }; + } + + @Post(':id/reviews') + async approve( + @Param('id') id: string, + @Headers('idempotency-key') key: string | undefined, + @Body() body: unknown, + @Req() request: AuthenticatedRequest, + ) { + if (!key) + throw new ApiError( + 400, + 'IDEMPOTENCY_KEY_REQUIRED', + '需要 Idempotency-Key', + ); + const parsed = pricingRateReviewRequestSchema.safeParse(body); + if (!parsed.success) + throw new ApiError( + 422, + 'VALIDATION_ERROR', + '请检查审核证据与账单样本', + parsed.error.issues.map((issue) => ({ + field: issue.path.join('.'), + message: issue.message, + })), + ); + return { + data: await this.service.approve( + uuid(id, 'rate_card_id'), + request.auth.userId, + uuid(key, 'Idempotency-Key'), + parsed.data, + ), + }; + } +} diff --git a/apps/api/src/pricing/pricing-admin.service.ts b/apps/api/src/pricing/pricing-admin.service.ts new file mode 100644 index 0000000..bae9768 --- /dev/null +++ b/apps/api/src/pricing/pricing-admin.service.ts @@ -0,0 +1,352 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { Database, DatabaseTransaction } from '@atlas/database'; +import type { + PricingRateCardAdmin, + PricingRateReviewRequest, +} from '@atlas/contracts'; +import { DATABASE } from '../database/database.module.js'; +import { ApiError } from '../common/api-error.js'; +import { + calculateLogisticsFee, + D, + fingerprint, + logisticsMerchandiseFits, + type LogisticsRule, +} from './pricing-engine.js'; + +type Tx = DatabaseTransaction; +type CardRow = { + id: string; + platform_id: string; + provider_code: string; + version: string; + source_name: string; + source_uri: string | null; + status: string; + fulfillment: string; + market_code: string; + effective_from: Date; + reviewed_at: Date | null; + reviewer_name: string | null; +}; +type TypeRow = PricingRateCardAdmin['types'][number] & Record; +type RuleRow = LogisticsRule & { + product_type_code: string; + transport_method_name: string; + status: string; + product_type_name: string; + transport_name: string; +}; + +@Injectable() +export class PricingAdminService { + constructor(@Inject(DATABASE) private readonly db: Database) {} + + async list(platformId: string): Promise { + const ids = await this.db.query<{ id: string }>( + `SELECT id FROM atlas.logistics_rate_cards WHERE platform_id=$1 AND fulfillment='rfbs' AND market_code='RU' ORDER BY created_at DESC`, + [platformId], + ); + const cards: PricingRateCardAdmin[] = []; + for (const row of ids.rows) cards.push(await this.detail(row.id)); + return cards; + } + + async detail(id: string): Promise { + return this.db.transaction((tx) => this.read(tx, id)); + } + + private async read(tx: Tx, id: string): Promise { + const card = ( + await tx.query( + `SELECT c.id,c.platform_id,p.code AS provider_code,c.version,c.source_name,c.source_uri,c.status,c.fulfillment,c.market_code,c.effective_from,c.reviewed_at,u.display_name AS reviewer_name + FROM atlas.logistics_rate_cards c JOIN atlas.logistics_providers p ON p.id=c.provider_id + LEFT JOIN atlas.users u ON u.id=c.reviewed_by_user_id WHERE c.id=$1`, + [id], + ) + ).rows[0]; + if (!card) + throw new ApiError(404, 'RATE_CARD_NOT_FOUND', '物流费率卡不存在'); + const types = ( + await tx.query( + `SELECT id,code::text,min_merchandise_value::text,max_merchandise_value::text,min_actual_weight_kg::text,max_actual_weight_kg::text,billing_basis::text, + merchandise_currency_code,min_value_inclusive,max_value_inclusive,min_actual_weight_inclusive,max_sum_dimensions_cm::text,max_longest_side_cm::text,max_second_longest_side_cm::text,volume_divisor::text,volume_condition_sum_gt_cm::text,volume_round_scale,billing_round_scale,max_volume_weight_kg::text,volume_limit_round_scale + FROM atlas.logistics_product_types WHERE rate_card_id=$1 ORDER BY code`, + [id], + ) + ).rows; + const rules = ( + await tx.query( + `SELECT r.id,r.product_type_id,t.code::text AS product_type_code,t.name AS product_type_name,r.transport_method_id,m.name AS transport_method_name,m.name AS transport_name, + r.fixed_fee_cny::text,r.per_billable_kg_fee_cny::text,r.min_charge_cny::text,r.surcharge_rules,r.status::text, + t.merchandise_currency_code,t.min_merchandise_value::text,t.max_merchandise_value::text,t.min_value_inclusive,t.max_value_inclusive, + t.min_actual_weight_kg::text,t.min_actual_weight_inclusive,t.max_actual_weight_kg::text,t.max_sum_dimensions_cm::text,t.max_longest_side_cm::text,t.max_second_longest_side_cm::text, + t.billing_basis::text,t.volume_divisor::text,t.volume_condition_sum_gt_cm::text,t.volume_round_scale,t.billing_round_scale,t.max_volume_weight_kg::text,t.volume_limit_round_scale, + r.updated_at AS rule_updated_at,t.updated_at AS type_updated_at,m.updated_at AS transport_updated_at,m.status::text AS transport_status + FROM atlas.logistics_rate_rules r JOIN atlas.logistics_product_types t ON t.id=r.product_type_id + JOIN atlas.logistics_transport_methods m ON m.id=r.transport_method_id AND m.rate_card_id=t.rate_card_id + WHERE t.rate_card_id=$1 ORDER BY t.code,m.code`, + [id], + ) + ).rows; + const review = + ( + await tx.query<{ + source_reference: string; + review_note: string; + samples: PricingRateReviewRequest['samples']; + reviewed_at: Date; + }>( + `SELECT source_reference,review_note,samples,reviewed_at FROM atlas.logistics_rate_card_reviews WHERE rate_card_id=$1`, + [id], + ) + ).rows[0] ?? null; + const { reviewer_name: reviewerName, ...publicCard } = card; + return { + ...publicCard, + effective_from: card.effective_from.toISOString(), + reviewed_at: card.reviewed_at?.toISOString() ?? null, + reviewer_name: reviewerName, + rules_hash: fingerprint({ + card: { + id: card.id, + version: card.version, + source_name: card.source_name, + source_uri: card.source_uri, + }, + types, + rules, + }), + types, + rules: rules.map((r) => ({ + id: r.id, + product_type_id: r.product_type_id, + product_type_code: r.product_type_code, + transport_method_id: r.transport_method_id, + transport_method_name: r.transport_method_name, + fixed_fee_cny: r.fixed_fee_cny, + per_billable_kg_fee_cny: r.per_billable_kg_fee_cny, + min_charge_cny: r.min_charge_cny, + status: r.status, + })), + review: review && { + ...review, + reviewed_at: review.reviewed_at.toISOString(), + }, + }; + } + + async approve( + id: string, + userId: string, + key: string, + input: PricingRateReviewRequest, + ) { + return this.db.transaction(async (tx) => { + await tx.query( + `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, + [`pricing-review:${userId}:${key}`], + ); + const card = ( + await tx.query<{ + status: string; + provider_code: string; + platform_code: string; + }>( + `SELECT c.status,p.code AS provider_code,platform.code AS platform_code + FROM atlas.logistics_rate_cards c JOIN atlas.logistics_providers p ON p.id=c.provider_id + JOIN atlas.platforms platform ON platform.id=c.platform_id WHERE c.id=$1 FOR UPDATE OF c`, + [id], + ) + ).rows[0]; + if (!card) + throw new ApiError( + 404, + 'RATE_CARD_NOT_FOUND', + '物流费率卡不存在', + ); + const requestHash = fingerprint(input); + const replay = ( + await tx.query<{ rate_card_id: string; request_hash: string }>( + `SELECT rate_card_id,request_hash FROM atlas.logistics_rate_card_reviews WHERE reviewer_user_id=$1 AND idempotency_key=$2`, + [userId, key], + ) + ).rows[0]; + if (replay) { + if ( + replay.rate_card_id !== id || + replay.request_hash !== requestHash + ) + throw new ApiError( + 409, + 'IDEMPOTENCY_KEY_CONFLICT', + '幂等键已用于其他审核请求', + ); + return this.read(tx, id); + } + if (card.status !== 'draft') + throw new ApiError( + 409, + 'RATE_CARD_NOT_DRAFT', + '费率卡已审核或不可审核,请刷新', + ); + if ( + card.provider_code.toUpperCase() !== 'CEL' || + card.platform_code.toUpperCase() !== 'OZON' + ) + throw new ApiError( + 422, + 'RATE_CARD_REVIEW_UNSUPPORTED', + '当前审核流程仅支持 Ozon CEL 费率', + ); + const detail = await this.read(tx, id); + if (detail.rules_hash !== input.rules_hash) + throw new ApiError( + 409, + 'RATE_CARD_RULES_CHANGED', + '费率规则已变化,请重新查看并复核', + ); + const types = new Set(detail.types.map((type) => type.id)); + const typeCodes = new Set(detail.types.map((type) => type.code)); + if ( + types.size !== 6 || + [ + 'ExtraSmall', + 'Budget', + 'Small', + 'Big', + 'PremiumSmall', + 'PremiumBig', + ].some((code) => !typeCodes.has(code)) || + detail.rules.length !== 16 || + detail.rules.some((r) => r.status !== 'draft') + ) + throw new ApiError( + 422, + 'RATE_CARD_INCOMPLETE', + 'CEL 费率应包含六种产品类型和十六条待审核规则', + ); + if ( + new Date(input.effective_from).getTime() > + Date.now() + 365 * 86400000 + ) + throw new ApiError( + 422, + 'RATE_CARD_EFFECTIVE_DATE_INVALID', + '生效日期不能晚于一年后', + ); + const fullRules = ( + await tx.query( + `SELECT r.*,t.code::text AS product_type_code,t.name AS product_type_name,m.name AS transport_name, + t.merchandise_currency_code,t.min_merchandise_value::text,t.max_merchandise_value::text,t.min_value_inclusive,t.max_value_inclusive, + t.min_actual_weight_kg::text,t.min_actual_weight_inclusive,t.max_actual_weight_kg::text,t.max_sum_dimensions_cm::text,t.max_longest_side_cm::text,t.max_second_longest_side_cm::text, + t.billing_basis::text,t.volume_divisor::text,t.volume_condition_sum_gt_cm::text,t.volume_round_scale,t.billing_round_scale,t.max_volume_weight_kg::text,t.volume_limit_round_scale,m.status::text AS transport_status + FROM atlas.logistics_rate_rules r JOIN atlas.logistics_product_types t ON t.id=r.product_type_id + JOIN atlas.logistics_transport_methods m ON m.id=r.transport_method_id WHERE t.rate_card_id=$1 FOR SHARE OF r,t,m`, + [id], + ) + ).rows; + if ( + fullRules.some( + (rule) => + rule.surcharge_rules.length > 0 || + (rule as RuleRow & { transport_status: string }) + .transport_status !== 'active', + ) + ) + throw new ApiError( + 422, + 'RATE_CARD_UNSUPPORTED_RULE', + '存在尚未支持的附加费或停用运输方式', + ); + const covered = new Set(); + const invoiceReferences = new Set(); + for (const [index, sample] of input.samples.entries()) { + const invoiceReference = sample.invoice_reference + .trim() + .toLocaleLowerCase(); + if (invoiceReferences.has(invoiceReference)) + throw new ApiError( + 422, + 'RATE_CARD_SAMPLE_DUPLICATE', + '账单编号不能重复使用', + ); + invoiceReferences.add(invoiceReference); + const rule = fullRules.find((r) => r.id === sample.rule_id); + if (!rule) + throw new ApiError( + 422, + 'RATE_CARD_SAMPLE_INVALID', + `第 ${index + 1} 条账单不属于当前费率卡`, + ); + covered.add(rule.product_type_id); + if ( + !logisticsMerchandiseFits( + rule, + sample.merchandise_value_cny, + ) + ) + throw new ApiError( + 422, + 'RATE_CARD_SAMPLE_INVALID', + `第 ${index + 1} 条账单货值不在该产品类型范围`, + ); + if (D(sample.shipping_weight_g).lt(1)) + throw new ApiError( + 422, + 'RATE_CARD_SAMPLE_INVALID', + `第 ${index + 1} 条账单重量至少 1 g`, + ); + const fee = calculateLogisticsFee(rule, { + shipping_weight_g: sample.shipping_weight_g, + length_mm: sample.length_mm, + width_mm: sample.width_mm, + height_mm: sample.height_mm, + status: 'confirmed', + }); + if ( + 'reason' in fee || + fee.logistics + .sub(sample.billed_logistics_cny) + .abs() + .gt('0.01') + ) + throw new ApiError( + 422, + 'RATE_CARD_SAMPLE_MISMATCH', + `第 ${index + 1} 条账单与费率计算不一致`, + ); + } + if ([...types].some((type) => !covered.has(type))) + throw new ApiError( + 422, + 'RATE_CARD_SAMPLE_INCOMPLETE', + '每种 CEL 产品类型至少需要一条真实账单样本', + ); + await tx.query( + `UPDATE atlas.logistics_rate_rules r SET status='active',updated_at=now() FROM atlas.logistics_product_types t WHERE r.product_type_id=t.id AND t.rate_card_id=$1`, + [id], + ); + await tx.query( + `UPDATE atlas.logistics_rate_cards SET status='active',effective_from=$2,reviewed_by_user_id=$3,reviewed_at=now(),updated_at=now() WHERE id=$1`, + [id, input.effective_from, userId], + ); + await tx.query( + `INSERT INTO atlas.logistics_rate_card_reviews(rate_card_id,reviewer_user_id,idempotency_key,request_hash,rules_hash,source_reference,review_note,samples,effective_from) + VALUES($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9)`, + [ + id, + userId, + key, + requestHash, + input.rules_hash, + input.source_reference, + input.review_note, + JSON.stringify(input.samples), + input.effective_from, + ], + ); + return this.read(tx, id); + }); + } +} diff --git a/apps/api/src/pricing/pricing-engine.ts b/apps/api/src/pricing/pricing-engine.ts new file mode 100644 index 0000000..0a07675 --- /dev/null +++ b/apps/api/src/pricing/pricing-engine.ts @@ -0,0 +1,302 @@ +import { normalizePricingInput } from '@atlas/contracts'; +import { randomUUID, createHash } from 'node:crypto'; +import { Decimal } from 'decimal.js'; +import type { PricingOption, PricingPreviewRequest } from '@atlas/contracts'; + +Decimal.set({ precision: 40, rounding: Decimal.ROUND_HALF_UP }); +export const D = (value: Decimal.Value) => new Decimal(value); +export function fingerprint(value: unknown): string { + const sort = (v: unknown): unknown => + v instanceof Date + ? v.toISOString() + : Array.isArray(v) + ? v.map(sort) + : v && typeof v === 'object' + ? Object.fromEntries( + Object.entries(v) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, x]) => [k, sort(x)]), + ) + : v; + return createHash('sha256') + .update(JSON.stringify(sort(value))) + .digest('hex'); +} +export type LogisticsRule = { + id: string; + product_type_id: string; + transport_method_id: string; + transport_name: string; + product_type_name: string; + fixed_fee_cny: string; + per_billable_kg_fee_cny: string; + min_charge_cny: string | null; + surcharge_rules: unknown[]; + merchandise_currency_code: string; + min_merchandise_value: string | null; + max_merchandise_value: string | null; + min_value_inclusive: boolean; + max_value_inclusive: boolean; + min_actual_weight_kg: string; + min_actual_weight_inclusive: boolean; + max_actual_weight_kg: string | null; + max_sum_dimensions_cm: string | null; + max_longest_side_cm: string | null; + max_second_longest_side_cm: string | null; + billing_basis: string; + volume_divisor: string | null; + volume_condition_sum_gt_cm: string | null; + volume_round_scale: number | null; + billing_round_scale: number | null; + max_volume_weight_kg: string | null; + volume_limit_round_scale: number | null; +}; +export type CommissionRule = { + id: string | null; + currency_code: string; + min_price: string | null; + max_price: string | null; + min_inclusive: boolean; + max_inclusive: boolean; + commission_rate: string; + version: string; +}; +const inRange = ( + n: Decimal, + min: string | null, + max: string | null, + includeMin: boolean, + includeMax: boolean, +) => + (min === null || (includeMin ? n.gte(min) : n.gt(min))) && + (max === null || (includeMax ? n.lte(max) : n.lt(max))); +const ceil = (n: Decimal, scale: number | null) => + scale === null ? n : n.toDecimalPlaces(scale, Decimal.ROUND_CEIL); +export const normalizeInput = normalizePricingInput; +/** 审核样本与正式报价共用包装适用性和运费计算,避免两套规则产生不同结果。 */ +export function calculateLogisticsFee( + r: LogisticsRule, + packageSpec: PricingPreviewRequest['package'], +): { billable: Decimal; logistics: Decimal } | { reason: string } { + if (r.surcharge_rules.length) + return { reason: '此规则含尚未支持的附加费,需完成审核后使用' }; + const weight = D(packageSpec.shipping_weight_g).div(1000); + const sides = [ + packageSpec.length_mm, + packageSpec.width_mm, + packageSpec.height_mm, + ] + .map((v) => D(v).div(10)) + .sort((a, b) => b.cmp(a)); + const sum = sides.reduce((a, b) => a.add(b), D(0)); + if ( + !inRange( + weight, + r.min_actual_weight_kg, + r.max_actual_weight_kg, + r.min_actual_weight_inclusive, + true, + ) || + (r.max_sum_dimensions_cm && sum.gt(r.max_sum_dimensions_cm)) || + (r.max_longest_side_cm && sides[0].gt(r.max_longest_side_cm)) || + (r.max_second_longest_side_cm && + sides[1].gt(r.max_second_longest_side_cm)) + ) + return { reason: '包装重量或尺寸超出物流范围' }; + let billable = weight; + if (r.billing_basis !== 'actual_only') { + if (!r.volume_divisor || D(r.volume_divisor).lte(0)) + return { reason: '体积重规则无效' }; + const volume = sides + .reduce((a, b) => a.mul(b), D(1)) + .div(r.volume_divisor); + if ( + r.max_volume_weight_kg && + ceil(volume, r.volume_limit_round_scale).gt(r.max_volume_weight_kg) + ) + return { reason: '体积重超限' }; + if ( + r.billing_basis === 'max_actual_volume' || + (r.volume_condition_sum_gt_cm && + sum.gt(r.volume_condition_sum_gt_cm)) + ) + billable = Decimal.max(weight, ceil(volume, r.volume_round_scale)); + } + billable = ceil(billable, r.billing_round_scale); + const logistics = Decimal.max( + billable.mul(r.per_billable_kg_fee_cny).add(r.fixed_fee_cny), + r.min_charge_cny ?? '0', + ); + return { billable, logistics }; +} + +export function logisticsMerchandiseFits( + r: LogisticsRule, + valueCny: string, +): boolean { + return ( + r.merchandise_currency_code === 'CNY' && + inRange( + D(valueCny), + r.min_merchandise_value, + r.max_merchandise_value, + r.min_value_inclusive, + r.max_value_inclusive, + ) + ); +} +/** 枚举自洽候选;先舍入目标币种售价,再回验佣金档与货值档,避免跨档误报。 */ +export function calculatePricing( + input: PricingPreviewRequest, + currency: string, + fx: string, + rules: LogisticsRule[], + commissions: CommissionRule[], +) { + if (!['CNY', 'RUB'].includes(currency)) + throw new Error('门店币种仅支持 CNY / RUB'); + const p = input.pricing, + exchange = D(fx), + purchase = D(input.purchase_cost_cny), + domestic = D(p.domestic_shipping_and_label_fee_cny); + const options: PricingOption[] = [], + unavailable_options: Array<{ + transport_method_id: string; + reason: string; + }> = []; + for (const transport of p.transport_method_ids) { + const reasons = new Set(); + for (const r of rules.filter( + (r) => r.transport_method_id === transport, + )) { + const fee = calculateLogisticsFee(r, input.package); + if ('reason' in fee) { + reasons.add(fee.reason); + continue; + } + const { billable, logistics } = fee; + for (const c of commissions) { + const totalRate = D(c.commission_rate) + .add(p.ad_rate) + .add(p.last_mile_rate) + .add(p.withdrawal_rate) + .add(p.other_rate); + if (totalRate.gte(1)) { + reasons.add('总扣费率达到或超过 100%'); + continue; + } + const factor = currency === 'RUB' ? exchange : D(1); + const sale = + p.mode === 'fixed_sale' + ? D(p.selling_price!) + : purchase + .mul(D(1).add(p.desired_markup_rate)) + .add(logistics) + .add(domestic) + .div(D(1).sub(totalRate)) + .mul(factor); + const rounded = sale.toDecimalPlaces( + 2, + p.mode === 'fixed_sale' + ? Decimal.ROUND_HALF_UP + : Decimal.ROUND_CEIL, + ), + cny = rounded.div(factor), + rub = cny.mul(exchange); + if ( + rounded.lte(0) || + !inRange( + c.currency_code === 'RUB' ? rub : cny, + c.min_price, + c.max_price, + c.min_inclusive, + c.max_inclusive, + ) || + !inRange( + r.merchandise_currency_code === 'RUB' ? rub : cny, + r.min_merchandise_value, + r.max_merchandise_value, + r.min_value_inclusive, + r.max_value_inclusive, + ) + ) { + reasons.add('售价不在该物流货值区间或佣金档内'); + continue; + } + const profit = cny + .mul(D(1).sub(totalRate)) + .sub(purchase) + .sub(logistics) + .sub(domestic), + list = rounded + .div(D(1).sub(p.list_price_discount_rate)) + .toDecimalPlaces(2, Decimal.ROUND_CEIL); + if ( + [ + rounded, + list, + rub, + list.div(factor).mul(exchange), + profit.mul(exchange), + logistics, + ].some((v) => v.abs().gte('100000000000000')) + ) { + reasons.add('金额超过可保存范围,请调整费率或输入'); + continue; + } + options.push({ + id: randomUUID(), + can_confirm: profit.gte(0), + transport_method_id: transport, + transport_name: r.transport_name, + product_type_id: r.product_type_id, + product_type_name: r.product_type_name, + logistics_rate_rule_id: r.id, + commission_rule_id: c.id, + commission_rate: c.commission_rate, + selling_price: rounded.toFixed(2), + list_price: list.toFixed(2), + currency_code: currency, + selling_price_rub: rub.toFixed(4), + list_price_rub: list.div(factor).mul(exchange).toFixed(4), + merchandise_value_cny: cny.toFixed(4), + profit_cny: profit.toFixed(2), + profit_rub: profit.mul(exchange).toFixed(4), + margin_rate: profit.div(cny).toFixed(6), + billable_weight_kg: billable.toFixed(3), + fees: { + logistics_cny: logistics.toFixed(4), + domestic_cny: domestic.toFixed(4), + commission_cny: cny.mul(c.commission_rate).toFixed(4), + ad_cny: cny.mul(p.ad_rate).toFixed(4), + last_mile_cny: cny.mul(p.last_mile_rate).toFixed(4), + withdrawal_cny: cny.mul(p.withdrawal_rate).toFixed(4), + other_cny: cny.mul(p.other_rate).toFixed(4), + }, + warnings: [ + ...(profit.lt(0) ? ['亏损报价不能确认'] : []), + ...(profit.lt(purchase.mul(p.desired_markup_rate)) + ? ['未达到目标加价利润'] + : []), + ...(!c.id ? ['使用临时佣金率'] : []), + ], + }); + } + } + if (!options.some((o) => o.transport_method_id === transport)) + unavailable_options.push({ + transport_method_id: transport, + reason: [...reasons].join(';') || '没有可用规则', + }); + } + options.sort((a, b) => D(a.selling_price).cmp(b.selling_price)); + const recommended = + options.find( + (o) => o.transport_method_id === p.default_transport_method_id, + ) ?? (!p.default_transport_method_id ? options[0] : undefined); + return { + options, + unavailable_options, + recommended_option_id: recommended?.id ?? null, + }; +} diff --git a/apps/api/src/pricing/pricing.controller.ts b/apps/api/src/pricing/pricing.controller.ts new file mode 100644 index 0000000..f820bf6 --- /dev/null +++ b/apps/api/src/pricing/pricing.controller.ts @@ -0,0 +1,190 @@ +import { + Body, + Controller, + Delete, + Get, + Headers, + Inject, + Param, + Patch, + Post, + Query, + Req, + Res, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { z } from 'zod'; +import { + pricingPreviewRequestSchema, + pricingPolicyRequestSchema, + 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 { PricingService } from './pricing.service.js'; +type Response = { status: (status: number) => unknown }; +function parse(schema: z.ZodType, value: unknown): T { + const result = schema.safeParse(value); + if (!result.success) + throw new ApiError( + 422, + 'VALIDATION_ERROR', + '请检查定价输入', + result.error.issues.map((i) => ({ + field: i.path.join('.'), + message: i.message, + })), + ); + return result.data; +} +function key(value: string | undefined) { + if (!value || value.length > 200) + throw new ApiError( + 400, + 'IDEMPOTENCY_KEY_REQUIRED', + '需要 Idempotency-Key', + ); + return value; +} +@ApiTags('Pricing') +@ApiBearerAuth('access-token') +@Controller() +export class PricingController { + constructor( + @Inject(PricingService) private readonly service: PricingService, + ) {} + @Get('pricing/categories') async categories( + @CurrentWorkspace() w: Workspace, + @Query('store_id') store: string, + @Query('q') q: string | undefined, + ) { + return { + data: await this.service.categories( + w.id, + parse(z.uuid(), store), + parse(z.string().max(200), q ?? ''), + ), + }; + } + @Get('pricing/catalog') async catalog( + @CurrentWorkspace() w: Workspace, + @Query('store_id') store: string, + ) { + return { + data: await this.service.catalog(w.id, parse(z.uuid(), store)), + }; + } + @Get('stores/:id/pricing-policies') async policies( + @CurrentWorkspace() w: Workspace, + @Param('id') id: string, + ) { + return { + data: (await this.service.catalog(w.id, parse(z.uuid(), id))) + .policies, + }; + } + @Post('pricing/quote-previews') async preview( + @CurrentWorkspace() w: Workspace, + @Req() req: AuthenticatedRequest, + @Headers('idempotency-key') k: string | undefined, + @Body() body: unknown, + @Res({ passthrough: true }) res: Response, + ) { + const r = await this.service.preview( + w.id, + req.auth.userId, + key(k), + parse(pricingPreviewRequestSchema, body), + ); + res.status(r.status); + return r.body; + } + @Post('stores/:id/pricing-policies') async create( + @CurrentWorkspace() w: Workspace, + @Req() req: AuthenticatedRequest, + @Param('id') id: string, + @Headers('idempotency-key') k: string | undefined, + @Body() body: unknown, + @Res({ passthrough: true }) res: Response, + ) { + const r = await this.service.writePolicy( + w.id, + req.auth.userId, + key(k), + parse(z.uuid(), id), + null, + 'create', + parse(pricingPolicyRequestSchema, body), + null, + ); + res.status(r.status); + return r.body; + } + @Patch('pricing-policies/:id') async update( + @CurrentWorkspace() w: Workspace, + @Req() req: AuthenticatedRequest, + @Param('id') id: string, + @Headers('idempotency-key') k: string | undefined, + @Headers('if-match') match: string | undefined, + @Body() body: unknown, + ) { + if (!match || !/^(?:[1-9]\d*|"[1-9]\d*")$/.test(match)) + throw new ApiError( + 428, + 'PRECONDITION_REQUIRED', + '需要方案版本 If-Match', + ); + return ( + await this.service.writePolicy( + w.id, + req.auth.userId, + key(k), + null, + parse(z.uuid(), id), + 'update', + parse(pricingPolicyRequestSchema, body), + Number(match.replaceAll('"', '')), + ) + ).body; + } + @Post('pricing-policies/:id/set-default') async defaultPolicy( + @CurrentWorkspace() w: Workspace, + @Req() req: AuthenticatedRequest, + @Param('id') id: string, + @Headers('idempotency-key') k: string | undefined, + @Res({ passthrough: true }) res: Response, + ) { + const r = await this.service.writePolicy( + w.id, + req.auth.userId, + key(k), + null, + parse(z.uuid(), id), + 'default', + null, + null, + ); + res.status(r.status); + return r.body; + } + @Delete('pricing-policies/:id') async archive( + @CurrentWorkspace() w: Workspace, + @Req() req: AuthenticatedRequest, + @Param('id') id: string, + @Headers('idempotency-key') k: string | undefined, + ) { + return ( + await this.service.writePolicy( + w.id, + req.auth.userId, + key(k), + null, + parse(z.uuid(), id), + 'archive', + null, + null, + ) + ).body; + } +} diff --git a/apps/api/src/pricing/pricing.module.ts b/apps/api/src/pricing/pricing.module.ts new file mode 100644 index 0000000..87258eb --- /dev/null +++ b/apps/api/src/pricing/pricing.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { IdempotencyService } from '../common/idempotency.service.js'; +import { PricingController } from './pricing.controller.js'; +import { PricingService } from './pricing.service.js'; +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], + providers: [ + PricingService, + PricingAdminService, + SystemAdminGuard, + IdempotencyService, + ], + exports: [PricingService], +}) +export class PricingModule {} diff --git a/apps/api/src/pricing/pricing.service.ts b/apps/api/src/pricing/pricing.service.ts new file mode 100644 index 0000000..c2995f8 --- /dev/null +++ b/apps/api/src/pricing/pricing.service.ts @@ -0,0 +1,850 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import type { Database, DatabaseTransaction } from '@atlas/database'; +import type { + PricingCatalog, + PricingPolicy, + PricingPolicyRequest, + PricingPreview, + PricingPreviewRequest, + PricingParameters, + SaveProductEditorDraftRequest, +} from '@atlas/contracts'; +import { pricingPreviewRequestSchema } from '@atlas/contracts'; +import { DATABASE } from '../database/database.module.js'; +import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js'; +import { ApiError } from '../common/api-error.js'; +import { IdempotencyService } from '../common/idempotency.service.js'; +import { + calculatePricing, + D, + fingerprint, + normalizeInput, + type CommissionRule, + type LogisticsRule, +} from './pricing-engine.js'; + +type Tx = DatabaseTransaction; +type Store = { + id: string; + name: string; + platform_id: string; + currency_code: string; +}; +type PolicyRow = { + id: string; + store_id: string; + name: string; + note: string | null; + revision: number; + is_default: boolean; + rate_card_id: string; + default_transport_method_id: string | null; + domestic_shipping_and_label_fee_cny: string; + ad_rate: string; + last_mile_rate: string; + withdrawal_rate: string; + other_rate: string; + desired_markup_rate: string; + list_price_discount_rate: string; + allow_commission_override: boolean; + fx_strategy: string; + fixed_cny_to_rub_rate: string | null; + transport_ids: string[]; +}; +const active = `status='active' AND effective_from<=now() AND (effective_to IS NULL OR effective_to>now())`; +@Injectable() +export class PricingService { + constructor( + @Inject(DATABASE) private readonly db: Database, + @Inject(IdempotencyService) + private readonly idempotency: IdempotencyService, + @Inject(APP_CONFIG) private readonly config: AppConfig, + ) {} + private get usableCardCondition() { + const reviewed = `(${active} AND reviewed_at IS NOT NULL AND reviewed_by_user_id IS NOT NULL)`; + return this.config.pricingAllowDraftRates + ? `(${reviewed} OR status='draft')` + : reviewed; + } + async store(tx: Tx, id: string): Promise { + const r = await tx.query( + `SELECT id,name,platform_id,currency_code FROM atlas.stores WHERE id=$1 AND deleted_at IS NULL`, + [id], + ); + if (!r.rows[0]) + throw new ApiError(404, 'STORE_NOT_FOUND', '门店不存在'); + if (!['CNY', 'RUB'].includes(r.rows[0].currency_code)) + throw new ApiError( + 422, + 'PRICING_CURRENCY_UNSUPPORTED', + '定价仅支持 CNY / RUB 门店', + ); + return r.rows[0]; + } + async policies(tx: Tx, storeId: string): Promise { + const result = await tx.query( + `SELECT p.*,ARRAY(SELECT transport_method_id FROM atlas.pricing_policy_transport_methods t WHERE t.pricing_policy_id=p.id AND is_enabled ORDER BY sort_order,transport_method_id) AS transport_ids FROM atlas.pricing_policies p WHERE p.store_id=$1 AND p.status='active' ORDER BY p.is_default DESC,p.name`, + [storeId], + ); + return result.rows.map((p) => ({ + id: p.id, + store_id: p.store_id, + name: p.name, + note: p.note, + revision: p.revision, + is_default: p.is_default, + allow_commission_override: p.allow_commission_override, + parameters: { + pricing_policy_id: p.id, + rate_card_id: p.rate_card_id, + transport_method_ids: p.transport_ids, + default_transport_method_id: p.default_transport_method_id, + domestic_shipping_and_label_fee_cny: + p.domestic_shipping_and_label_fee_cny, + ad_rate: p.ad_rate, + last_mile_rate: p.last_mile_rate, + withdrawal_rate: p.withdrawal_rate, + other_rate: p.other_rate, + desired_markup_rate: p.desired_markup_rate, + list_price_discount_rate: p.list_price_discount_rate, + mode: 'target_markup', + selling_price: null, + temporary_commission_rate: null, + fx: + p.fx_strategy === 'fixed' + ? { + mode: 'policy_fixed', + rate: p.fixed_cny_to_rub_rate!, + } + : { mode: 'system' }, + }, + })); + } + async categories(workspaceId: string, storeId: string, q: string) { + return this.db.workspaceTransaction(workspaceId, async (tx) => { + const store = await this.store(tx, storeId); + return ( + await tx.query<{ id: string; name: string; path: string }>( + `SELECT id,name,path FROM atlas.platform_product_categories WHERE platform_id=$1 AND is_active AND (name ILIKE $2 OR path ILIKE $2) ORDER BY path LIMIT 50`, + [store.platform_id, `%${q}%`], + ) + ).rows; + }); + } + async exchange(tx: Tx) { + const r = await tx.query<{ + id: string; + rate: string; + effective_on: string; + stale: boolean; + }>( + `SELECT id,rate::text,effective_on::text,(effective_on::timestamp AT TIME ZONE 'Europe/Moscow') { + return this.db.workspaceTransaction(workspace, async (tx) => { + const store = await this.store(tx, storeId); + const cards = await tx.query<{ + id: string; + name: string; + version: string; + development_only: boolean; + transports: Array<{ id: string; name: string }>; + }>( + `SELECT c.id,c.source_name AS name,c.version,(c.status='draft') AS development_only,coalesce((SELECT jsonb_agg(jsonb_build_object('id',t.id,'name',t.name) ORDER BY t.name) FROM atlas.logistics_transport_methods t WHERE t.rate_card_id=c.id AND t.status='active'),'[]') AS transports FROM atlas.logistics_rate_cards c WHERE c.platform_id=$1 AND c.fulfillment='rfbs' AND c.market_code='RU' AND ${this.usableCardCondition} ORDER BY c.status='active' DESC,c.effective_from DESC`, + [store.platform_id], + ); + const pendingCards = await tx.query<{ + id: string; + name: string; + version: string; + status: 'draft'; + }>( + `SELECT id,source_name AS name,version,status FROM atlas.logistics_rate_cards WHERE platform_id=$1 AND fulfillment='rfbs' AND market_code='RU' AND status='draft' ORDER BY created_at DESC`, + [store.platform_id], + ); + return { + store, + rate_cards: cards.rows, + pending_rate_cards: pendingCards.rows, + policies: await this.policies(tx, storeId), + exchange_rate: await this.exchange(tx), + }; + }); + } + /** 开发模式可复算草稿;保存确认时复读规则,正式模式仍只接受已审核版本。 */ + async rules(tx: Tx, input: PricingPreviewRequest, store: Store) { + const card = ( + await tx.query<{ id: string; version: string; status: string; updated_at: Date }>( + `SELECT * FROM atlas.logistics_rate_cards WHERE id=$1 AND platform_id=$2 AND fulfillment=$3 AND market_code='RU' AND ${this.usableCardCondition} FOR SHARE`, + [ + input.pricing.rate_card_id, + store.platform_id, + input.fulfillment, + ], + ) + ).rows[0]; + if (!card) + 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`, + [input.platform_product_category_id, store.platform_id], + ); + if (!category.rowCount) + throw new ApiError( + 422, + 'PRICING_CATEGORY_REQUIRED', + '请选择当前平台的有效类目', + ); + if (input.pricing.pricing_policy_id) + await tx.query( + 'SELECT id FROM atlas.pricing_policies WHERE id=$1 FOR SHARE', + [input.pricing.pricing_policy_id], + ); + const policy = input.pricing.pricing_policy_id + ? (await this.policies(tx, store.id)).find( + (p) => p.id === input.pricing.pricing_policy_id, + ) + : null; + if ( + input.pricing.pricing_policy_id && + (!policy || policy.parameters.rate_card_id !== card.id) + ) + throw new ApiError( + 422, + 'PRICING_POLICY_UNAVAILABLE', + '方案不可用或费率卡不匹配', + ); + const logistics = ( + await tx.query( + `SELECT t.*,r.id,r.product_type_id,r.transport_method_id,m.name AS transport_name,t.name AS product_type_name,r.fixed_fee_cny,r.per_billable_kg_fee_cny,r.min_charge_cny,r.surcharge_rules,r.updated_at AS rule_updated_at,m.updated_at AS transport_updated_at FROM atlas.logistics_rate_rules r JOIN atlas.logistics_product_types t ON t.id=r.product_type_id JOIN atlas.logistics_transport_methods m ON m.id=r.transport_method_id AND m.rate_card_id=t.rate_card_id WHERE t.rate_card_id=$1 AND r.status=$3 AND m.status='active' AND m.id=ANY($2::uuid[]) ORDER BY r.id FOR SHARE OF r,t,m`, + [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 + : []; + if ( + !commissions.length && + input.pricing.temporary_commission_rate !== null && + (!policy || policy.allow_commission_override) + ) + commissions = [ + { + id: null, + currency_code: 'RUB', + min_price: null, + max_price: null, + min_inclusive: true, + max_inclusive: true, + commission_rate: input.pricing.temporary_commission_rate, + version: 'temporary', + }, + ]; + if (!commissions.length) + throw new ApiError( + 422, + 'PRICING_COMMISSION_UNAVAILABLE', + '该类目尚无有效佣金规则;允许临时佣金的方案可在详情中补充', + ); + if ( + commissions.some((c) => c.id) && + input.pricing.temporary_commission_rate !== null + ) + throw new ApiError( + 422, + 'PRICING_COMMISSION_OVERRIDE_FORBIDDEN', + '已有正式佣金规则,不能临时覆盖', + ); + return { + card, + policy, + logistics, + commissions, + hash: fingerprint({ + card, + policy, + logistics, + commissions, + mapping, + }), + }; + } + async preview( + workspaceId: string, + userId: string, + key: string, + input: PricingPreviewRequest, + ) { + return this.idempotency.run( + { + workspaceId, + userId, + key, + method: 'POST', + path: '/api/pricing/quote-previews', + request: input, + }, + async (tx) => { + const store = await this.store(tx, input.store_id); + if (input.binding) { + const binding = input.binding; + const profile = ( + await tx.query( + `SELECT id FROM atlas.product_platform_profiles WHERE id=$1 AND platform_id=$2`, + [binding.profile_id, store.platform_id], + ) + ).rows[0]; + if (!profile) + throw new ApiError( + 404, + 'PROFILE_NOT_FOUND', + '商品发布配置不存在', + ); + if ( + binding.variant_id && + !( + await tx.query( + `SELECT l.id FROM atlas.product_variant_platform_listings l JOIN atlas.product_variants v ON v.id=l.product_variant_id WHERE l.profile_id=$1 AND l.product_variant_id=$2 AND l.status<>'imported' AND v.deleted_at IS NULL`, + [binding.profile_id, binding.variant_id], + ) + ).rowCount + ) + throw new ApiError( + 422, + 'PRICING_VARIANT_UNAVAILABLE', + 'SKU 不存在或已经上架', + ); + } + const rules = await this.rules(tx, input, store), + exchange = await this.exchange(tx); + let fx: string, origin: string; + if (input.pricing.fx.mode === 'policy_fixed') { + const fixed = rules.policy?.parameters.fx; + if (fixed?.mode !== 'policy_fixed' || !fixed.rate) + throw new ApiError( + 422, + 'PRICING_FX_REQUIRED', + '方案没有固定汇率', + ); + fx = fixed.rate; + origin = 'policy_fixed'; + } else if (input.pricing.fx.mode === 'one_time_override') { + fx = input.pricing.fx.rate!; + origin = 'quote_override'; + } else { + if (!exchange) + throw new ApiError( + 422, + 'PRICING_FX_UNAVAILABLE', + '尚无 CBR 汇率,请稍后重试或明确填写本次汇率', + ); + fx = D(exchange.rate).toDecimalPlaces(8).toString(); + origin = 'system_rate'; + } + const normalized = normalizeInput(input), + id = randomUUID(), + expires = new Date(Date.now() + 15 * 60_000).toISOString(); + const result: PricingPreview = { + preview_id: id, + expires_at: expires, + input_hash: fingerprint(normalized), + ...calculatePricing( + normalized, + store.currency_code, + fx, + rules.logistics, + rules.commissions, + ), + context: { + currency_code: store.currency_code, + fx_rate: fx, + fx_origin: origin, + rule_version: rules.card.version, + development_only: rules.card.status === 'draft', + exchange_rate: + origin === 'system_rate' ? exchange : null, + warnings: + origin === 'system_rate' && exchange?.stale + ? ['CBR 汇率超过 72 小时,请确认参考日期'] + : [], + }, + }; + await tx.query( + `INSERT INTO atlas.pricing_previews(id,workspace_id,user_id,store_id,platform_id,input,input_hash,rules_hash,result,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, + [ + id, + workspaceId, + userId, + store.id, + store.platform_id, + JSON.stringify(normalized), + result.input_hash, + rules.hash, + JSON.stringify(result), + expires, + ], + ); + return { status: 201, body: { data: result } }; + }, + ); + } + async writePolicy( + workspaceId: string, + userId: string, + key: string, + storeId: string | null, + id: string | null, + action: 'create' | 'update' | 'default' | 'archive', + input: PricingPolicyRequest | null, + revision: number | null, + ) { + return this.idempotency + .run( + { + workspaceId, + userId, + key, + method: + action === 'update' + ? 'PATCH' + : action === 'archive' + ? 'DELETE' + : 'POST', + path: id + ? `/api/pricing-policies/${id}/${action}` + : `/api/stores/${storeId}/pricing-policies`, + request: { input, revision }, + }, + async (tx) => { + let current: + { store_id: string; revision: number } | undefined; + if (id) { + current = ( + await tx.query<{ + store_id: string; + revision: number; + }>( + `SELECT store_id,revision FROM atlas.pricing_policies WHERE id=$1 AND status='active'`, + [id], + ) + ).rows[0]; + if (!current) + throw new ApiError( + 404, + 'PRICING_POLICY_NOT_FOUND', + '方案不存在', + ); + storeId = current.store_id; + } + const store = await this.store(tx, storeId!); + await tx.query( + `SELECT id FROM atlas.stores WHERE id=$1 FOR UPDATE`, + [store.id], + ); + if (id) + current = ( + await tx.query<{ + store_id: string; + revision: number; + }>( + `SELECT store_id,revision FROM atlas.pricing_policies WHERE id=$1 AND status='active' FOR UPDATE`, + [id], + ) + ).rows[0]; + if (id && !current) + throw new ApiError( + 404, + 'PRICING_POLICY_NOT_FOUND', + '方案不存在', + ); + if (action === 'update' && current?.revision !== revision) + throw new ApiError( + 409, + 'VERSION_CONFLICT', + '方案已更新,请刷新后重试', + ); + if (action === 'default') { + await tx.query( + `UPDATE atlas.pricing_policies SET is_default=false,revision=revision+1,updated_at=now() WHERE store_id=$1 AND is_default AND id<>$2`, + [store.id, id], + ); + await tx.query( + `UPDATE atlas.pricing_policies SET is_default=true,revision=revision+1,updated_at=now() WHERE id=$1`, + [id], + ); + } else if (action === 'archive') + await tx.query( + `UPDATE atlas.pricing_policies SET status='archived',is_default=false,revision=revision+1,updated_at=now() WHERE id=$1`, + [id], + ); + else { + const p = input!.parameters; + if (p.fx.mode === 'policy_fixed' && !p.fx.rate) + throw new ApiError( + 422, + 'PRICING_FX_REQUIRED', + '固定汇率不能为空', + ); + if ( + p.default_transport_method_id && + !p.transport_method_ids.includes( + p.default_transport_method_id, + ) + ) + throw new ApiError( + 422, + 'PRICING_TRANSPORT_INVALID', + '默认运输方式必须包含在比较列表中', + ); + const card = ( + await tx.query( + `SELECT id FROM atlas.logistics_rate_cards WHERE id=$1 AND platform_id=$2 AND fulfillment='rfbs' AND market_code='RU' AND ${this.usableCardCondition}`, + [p.rate_card_id, store.platform_id], + ) + ).rowCount; + const transports = ( + await tx.query( + `SELECT id FROM atlas.logistics_transport_methods WHERE rate_card_id=$1 AND status='active' AND id=ANY($2::uuid[])`, + [p.rate_card_id, p.transport_method_ids], + ) + ).rowCount; + if ( + !card || + transports !== new Set(p.transport_method_ids).size + ) + throw new ApiError( + 422, + 'PRICING_RULES_UNAVAILABLE', + '费率卡或运输方式不可用', + ); + const values = [ + input!.name, + input!.note, + p.rate_card_id, + p.default_transport_method_id, + p.domestic_shipping_and_label_fee_cny, + p.ad_rate, + p.last_mile_rate, + p.withdrawal_rate, + p.other_rate, + p.desired_markup_rate, + p.list_price_discount_rate, + input!.allow_commission_override, + p.fx.mode === 'policy_fixed' ? 'fixed' : 'system', + p.fx.mode === 'policy_fixed' ? p.fx.rate : null, + ]; + if (action === 'create') { + id = randomUUID(); + await tx.query( + `INSERT INTO atlas.pricing_policies(name,note,rate_card_id,default_transport_method_id,domestic_shipping_and_label_fee_cny,ad_rate,last_mile_rate,withdrawal_rate,other_rate,desired_markup_rate,list_price_discount_rate,allow_commission_override,fx_strategy,fixed_cny_to_rub_rate,id,workspace_id,store_id,platform_id) VALUES(${values.map((_, i) => `$${i + 1}`).join(',')},$15,$16,$17,$18)`, + [ + ...values, + id, + workspaceId, + store.id, + store.platform_id, + ], + ); + } else + await tx.query( + `UPDATE atlas.pricing_policies SET name=$1,note=$2,rate_card_id=$3,default_transport_method_id=$4,domestic_shipping_and_label_fee_cny=$5,ad_rate=$6,last_mile_rate=$7,withdrawal_rate=$8,other_rate=$9,desired_markup_rate=$10,list_price_discount_rate=$11,allow_commission_override=$12,fx_strategy=$13,fixed_cny_to_rub_rate=$14,revision=revision+1,updated_at=now() WHERE id=$15`, + [...values, id], + ); + await tx.query( + `DELETE FROM atlas.pricing_policy_transport_methods WHERE pricing_policy_id=$1`, + [id], + ); + for (const [i, t] of [ + ...new Set(p.transport_method_ids), + ].entries()) + await tx.query( + `INSERT INTO atlas.pricing_policy_transport_methods(workspace_id,pricing_policy_id,transport_method_id,sort_order) VALUES($1,$2,$3,$4)`, + [workspaceId, id, t, i], + ); + } + return { + status: action === 'create' ? 201 : 200, + body: { + data: + action === 'archive' + ? { id, archived: true } + : (await this.policies(tx, store.id)).find( + (p) => p.id === id, + ), + }, + }; + }, + ) + .catch((error: unknown) => { + if ( + error && + typeof error === 'object' && + 'code' in error && + error.code === '23505' + ) + throw new ApiError( + 409, + 'PRICING_POLICY_NAME_CONFLICT', + '此门店已存在同名方案,请使用其他名称', + ); + throw error; + }); + } + /** 必须在 editor-draft 的现有事务中调用,任何确认失败使商品修改一起回滚。 */ + async persistDraft( + tx: Tx, + workspaceId: string, + userId: string, + profileId: string, + input: SaveProductEditorDraftRequest, + created: Array<{ client_key: string; id: string }>, + ) { + for (const v of input.variants) { + const variantId = + v.id ?? created.find((c) => c.client_key === v.client_key)!.id; + const listing = ( + await tx.query<{ + id: string; + status: string; + pricing_draft: PricingParameters | null; + pricing_quote_id: string | null; + }>( + `SELECT id,status,pricing_draft,pricing_quote_id FROM atlas.product_variant_platform_listings WHERE profile_id=$1 AND product_variant_id=$2 FOR UPDATE`, + [profileId, variantId], + ) + ).rows[0]; + if (listing.status === 'imported') continue; + const state = ( + await tx.query<{ + product_id: string; + platform_id: string; + store_id: string; + currency_code: string; + category_id: string; + fulfillment: 'rfbs'; + purchase_cost_cny: string; + package: PricingPreviewRequest['package']; + }>( + `SELECT v.product_id,p.platform_id,p.target_store_id AS store_id,s.currency_code,p.platform_product_category_id AS category_id,p.fulfillment,v.purchase_cost_cny::text,jsonb_build_object('shipping_weight_g',coalesce(v.shipping_weight_g,g.default_shipping_weight_g)::text,'length_mm',coalesce(v.package_length_mm,g.default_package_length_mm)::text,'width_mm',coalesce(v.package_width_mm,g.default_package_width_mm)::text,'height_mm',coalesce(v.package_height_mm,g.default_package_height_mm)::text,'status',coalesce(v.package_spec_status,g.package_spec_status)) AS package FROM atlas.product_variants v JOIN atlas.products g ON g.id=v.product_id JOIN atlas.product_platform_profiles p ON p.id=$1 JOIN atlas.stores s ON s.id=p.target_store_id WHERE v.id=$2`, + [profileId, variantId], + ) + ).rows[0]; + const params = + v.pricing_draft === undefined + ? listing.pricing_draft + : v.pricing_draft; + const parsed = pricingPreviewRequestSchema.safeParse({ + store_id: state.store_id, + platform_product_category_id: state.category_id, + fulfillment: state.fulfillment, + purchase_cost_cny: + state.purchase_cost_cny === null + ? null + : D(state.purchase_cost_cny).toFixed(2), + package: state.package, + pricing: params, + binding: { + profile_id: profileId, + variant_id: v.id, + client_key: v.id ? null : v.client_key, + }, + }); + const normalized = parsed.success + ? normalizeInput(parsed.data) + : null; + const fail = () => { + throw new ApiError( + 409, + 'PRICING_PREVIEW_STALE', + `${v.display_name ?? 'SKU'} 的报价已失效,请重新试算或仅保存草稿`, + [ + { + field: `variants.${variantId}.pricing_selection`, + code: 'PRICING_PREVIEW_STALE', + message: '报价输入、规则或有效期已变化', + }, + ], + ); + }; + if (!v.pricing_selection) { + // 旧报价保留供审计,当前引用仅在输入和归属仍一致时保持有效。 + const old = listing.pricing_quote_id + ? ( + await tx.query<{ + input_snapshot: { + input: PricingPreviewRequest; + context: PricingPreview['context']; + }; + }>( + `SELECT input_snapshot FROM atlas.pricing_quotes WHERE id=$1`, + [listing.pricing_quote_id], + ) + ).rows[0] + : null; + const same = + normalized && + old && + old.input_snapshot.context.currency_code === + state.currency_code && + fingerprint({ ...normalized, binding: null }) === + fingerprint({ + ...old.input_snapshot.input, + binding: null, + }); + await tx.query( + `UPDATE atlas.product_variant_platform_listings SET pricing_draft=$2,pricing_valid=$3,currency_code=$4,pricing_quote_id=CASE WHEN store_id=$5 THEN pricing_quote_id ELSE NULL END WHERE id=$1`, + [ + listing.id, + params ? JSON.stringify(params) : null, + Boolean(same), + state.currency_code, + old?.input_snapshot.input.store_id ?? state.store_id, + ], + ); + continue; + } + if (!normalized) fail(); + const selection = v.pricing_selection; + const preview = ( + await tx.query<{ + input: PricingPreviewRequest; + input_hash: string; + rules_hash: string; + result: PricingPreview; + valid: boolean; + }>( + `SELECT input,input_hash,rules_hash,result,expires_at>now() AS valid FROM atlas.pricing_previews WHERE id=$1 AND user_id=$2 FOR SHARE`, + [selection.preview_id, userId], + ) + ).rows[0]; + if ( + !preview || + !preview.valid || + selection.input_hash !== preview.input_hash || + fingerprint(normalized) !== preview.input_hash + ) + fail(); + const store = await this.store(tx, state.store_id); + let currentRules: Awaited>; + try { + currentRules = await this.rules(tx, normalized!, store); + } catch (error) { + if (error instanceof ApiError && error.getStatus() === 422) + return fail(); + throw error; + } + if ( + currentRules.hash !== preview.rules_hash || + preview.result.context.currency_code !== state.currency_code + ) + fail(); + const option = preview.result.options.find( + (o) => o.id === selection.option_id, + ); + if (!option || !option.can_confirm) fail(); + const o = option!, + quoteId = randomUUID(), + selectedId = randomUUID(); + const savedInput = { + ...normalized!, + binding: { + profile_id: profileId, + variant_id: variantId, + client_key: null, + }, + }; + await tx.query( + `INSERT INTO atlas.pricing_quotes(id,workspace_id,store_id,platform_id,pricing_policy_id,product_variant_id,product_id,profile_id,platform_product_category_id,fulfillment,purchase_cost_cny,package_weight_kg,package_length_cm,package_width_cm,package_height_cm,fx_cny_to_rub,fx_rate_origin,input_snapshot,status,selected_option_id,calculated_at,confirmed_at,created_by_user_id,input_hash) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,'confirmed',$19,now(),now(),$20,$21)`, + [ + quoteId, + workspaceId, + state.store_id, + state.platform_id, + params!.pricing_policy_id, + variantId, + state.product_id, + profileId, + state.category_id, + state.fulfillment, + state.purchase_cost_cny, + D(state.package.shipping_weight_g).div(1000).toString(), + D(state.package.length_mm).div(10).toString(), + D(state.package.width_mm).div(10).toString(), + D(state.package.height_mm).div(10).toString(), + preview.result.context.fx_rate, + preview.result.context.fx_origin, + JSON.stringify({ + input: savedInput, + context: preview.result.context, + rules: currentRules, + }), + selectedId, + userId, + fingerprint(savedInput), + ], + ); + for (const candidate of preview.result.options) { + const f = candidate.fees, + fx = D(preview.result.context.fx_rate); + await tx.query( + `INSERT INTO atlas.pricing_quote_options(id,workspace_id,quote_id,logistics_rate_rule_id,commission_rule_id,logistics_product_type_id,transport_method_id,merchandise_value_cny,selling_price_rub,list_price_rub,billable_weight_kg,logistics_fee_cny,commission_fee_rub,domestic_fee_cny,ad_fee_rub,last_mile_fee_rub,withdrawal_fee_rub,other_fee_rub,profit_rub,calculation_snapshot) VALUES(${Array.from({ length: 20 }, (_, i) => `$${i + 1}`).join(',')})`, + [ + candidate.id === o.id ? selectedId : randomUUID(), + workspaceId, + quoteId, + candidate.logistics_rate_rule_id, + candidate.commission_rule_id, + candidate.product_type_id, + candidate.transport_method_id, + candidate.merchandise_value_cny, + candidate.selling_price_rub, + candidate.list_price_rub, + candidate.billable_weight_kg, + f.logistics_cny, + fx.mul(f.commission_cny).toFixed(4), + f.domestic_cny, + fx.mul(f.ad_cny).toFixed(4), + fx.mul(f.last_mile_cny).toFixed(4), + fx.mul(f.withdrawal_cny).toFixed(4), + fx.mul(f.other_cny).toFixed(4), + candidate.profit_rub, + JSON.stringify(candidate), + ], + ); + } + await tx.query( + `UPDATE atlas.product_variant_platform_listings SET pricing_quote_id=$2,pricing_draft=$3,pricing_valid=true,selling_price=$4,list_price=$5,currency_code=$6 WHERE id=$1`, + [ + listing.id, + quoteId, + JSON.stringify(params), + o.selling_price, + o.list_price, + o.currency_code, + ], + ); + } + } +} diff --git a/apps/api/src/product-editor/product-editor.service.ts b/apps/api/src/product-editor/product-editor.service.ts index bb3827a..69d4b09 100644 --- a/apps/api/src/product-editor/product-editor.service.ts +++ b/apps/api/src/product-editor/product-editor.service.ts @@ -1,3 +1,5 @@ +import { PricingService } from '../pricing/pricing.service.js'; +import type { PricingParameters, PricingOption } from '@atlas/contracts'; import { Inject, Injectable } from '@nestjs/common'; import type { ProductEditorContext, @@ -10,6 +12,7 @@ import type { Database, DatabaseTransaction } from '@atlas/database'; import { ApiError } from '../common/api-error.js'; import { IdempotencyService } from '../common/idempotency.service.js'; import { DATABASE } from '../database/database.module.js'; +import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js'; import { type ProductRow, type ProfileRow, @@ -37,6 +40,7 @@ import { @Injectable() export class ProductEditorService { + private readonly pricing: PricingService; private readonly catalog: ProductEditorCatalogOperations; private readonly validationOperations: ProductEditorValidationOperations; private readonly publishPreflightOperations: ProductEditorPublishPreflightOperations; @@ -46,7 +50,9 @@ export class ProductEditorService { @Inject(IdempotencyService) private readonly idempotency: IdempotencyService, @Inject(DATABASE) private readonly database: Database, + @Inject(APP_CONFIG) config: AppConfig, ) { + this.pricing = new PricingService(database, idempotency, config); this.catalog = new ProductEditorCatalogOperations(this.database, { requireProfileForPlatform: (...args) => this.requireProfileForPlatform(...args), @@ -219,6 +225,9 @@ export class ProductEditorService { description: string | null; tags: unknown; status: string; + pricing_draft: PricingParameters | null; + pricing_result: PricingOption | null; + pricing_valid: boolean; selling_price: string | null; list_price: string | null; currency_code: string | null; @@ -239,7 +248,8 @@ export class ProductEditorService { `SELECT v.id, v.source_variant_key, v.source_sku, v.display_name, v.sort_order, v.purchase_cost_cny::numeric(18, 2)::text AS purchase_cost_cny, l.id AS listing_id, l.offer_id, l.title, l.description, l.tags, l.status, l.selling_price::text, - l.list_price::text, l.currency_code, v.specification_values, + l.list_price::text, l.currency_code, l.pricing_draft, l.pricing_valid, + (SELECT calculation_snapshot FROM atlas.pricing_quote_options o JOIN atlas.pricing_quotes q ON q.id=o.quote_id AND q.selected_option_id=o.id WHERE q.id=l.pricing_quote_id) AS pricing_result, v.specification_values, v.physical_length_mm::text, v.physical_width_mm::text, v.physical_height_mm::text, v.net_weight_g::text, v.physical_spec_status, v.package_length_mm::text, v.package_width_mm::text, v.package_height_mm::text, @@ -400,6 +410,9 @@ export class ProductEditorService { id: variant.listing_id, offer_id: variant.offer_id, status: variant.status, + pricing_draft: variant.pricing_draft, + pricing_result: variant.pricing_result, + pricing_valid: variant.pricing_valid, selling_price: variant.selling_price, list_price: variant.list_price, currency_code: variant.currency_code, @@ -1025,6 +1038,7 @@ export class ProductEditorService { WHERE product_id = $1 AND deleted_at IS NULL AND NOT (id = ANY($2::uuid[]))`, [productId, retainedIds], ); + await this.pricing.persistDraft(transaction, workspaceId, userId, profile.id, input, createdVariants); await this.persistDynamicAttributes(transaction, { productId, profile, diff --git a/apps/api/src/product-editor/product-editor.validation.ts b/apps/api/src/product-editor/product-editor.validation.ts index 6356226..fb7960d 100644 --- a/apps/api/src/product-editor/product-editor.validation.ts +++ b/apps/api/src/product-editor/product-editor.validation.ts @@ -29,6 +29,8 @@ type VariantValidationRow = { listing_status: string; 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; @@ -283,6 +285,18 @@ export class ProductEditorValidationOperations { field: `variants.${variant.id}.offer_id`, message: '请填写卖家货号', }); + if (!variant.pricing_valid) + issues.push({ + code: 'PRICING_REQUIRED', + 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', @@ -348,7 +362,8 @@ 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.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, @@ -359,11 +374,12 @@ 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[])) GROUP BY v.id, v.display_name, listing.status, listing.offer_id, - listing.selling_price, product.default_package_length_mm, + listing.selling_price, listing.pricing_valid, product.default_package_length_mm, product.default_package_width_mm, product.default_package_height_mm, product.default_shipping_weight_g, product.package_spec_status ORDER BY v.sort_order, v.id`, diff --git a/apps/api/test/pricing-admin.e2e.test.ts b/apps/api/test/pricing-admin.e2e.test.ts new file mode 100644 index 0000000..b884acf --- /dev/null +++ b/apps/api/test/pricing-admin.e2e.test.ts @@ -0,0 +1,294 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import type { AddressInfo } from 'node:net'; +import { Pool } from 'pg'; +import type { Database } from '@atlas/database'; +import type { NestExpressApplication } from '@nestjs/platform-express'; +import { afterAll, beforeAll, describe, it } from 'vitest'; +import { DATABASE } from '../src/database/database.module.js'; +import { createApplication } from '../src/main.js'; + +let app: NestExpressApplication; +let db: Database; +let migrator: Pool; +let base = '', + token = '', + user = '', + workspace = '', + card = '', + platform = ''; +const request = async ( + path: string, + method = 'GET', + body?: unknown, + key?: string, +) => { + const response = await fetch(`${base}/api${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(key ? { 'Idempotency-Key': key } : {}), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return { status: response.status, body: (await response.json()) as any }; +}; + +describe('系统物流费率审核', () => { + beforeAll(async () => { + Object.assign(process.env, { + NODE_ENV: 'test', + SWAGGER_ENABLED: 'false', + AUTH_REGISTER_IP_LIMIT: '100', + AUTH_REGISTER_EMAIL_LIMIT: '100', + }); + app = (await createApplication()).app; + db = app.get(DATABASE); + migrator = new Pool({ + connectionString: process.env.MIGRATOR_DATABASE_URL, + max: 1, + }); + await app.listen(0, '127.0.0.1'); + base = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`; + const registered = await request('/auth/register', 'POST', { + email: `pricing-review-${randomUUID()}@example.test`, + password: 'pricing-review-test-password-123', + password_confirmation: 'pricing-review-test-password-123', + }); + assert.equal(registered.status, 201, JSON.stringify(registered.body)); + token = registered.body.data.access_token; + user = registered.body.data.user.id; + workspace = registered.body.data.workspace.id; + const scope = await db.query<{ + platform_id: string; + provider_id: string; + }>( + `SELECT platform.id AS platform_id,provider.id AS provider_id FROM atlas.platforms platform CROSS JOIN atlas.logistics_providers provider WHERE upper(platform.code::text)='OZON' AND upper(provider.code::text)='CEL' LIMIT 1`, + ); + platform = scope.rows[0].platform_id; + const provider = scope.rows[0].provider_id; + card = ( + await db.query<{ id: string }>( + `INSERT INTO atlas.logistics_rate_cards(provider_id,platform_id,fulfillment,market_code,version,source_name,status,effective_from) + VALUES($1,$2,'rfbs','RU',$3,'自动化审核测试,未来生效','draft',now()) RETURNING id`, + [provider, platform, `qa-${randomUUID()}`], + ) + ).rows[0].id; + const methods: string[] = []; + for (const code of ['express', 'standard', 'economy']) { + methods.push( + ( + await db.query<{ id: string }>( + `INSERT INTO atlas.logistics_transport_methods(rate_card_id,code,name) VALUES($1,$2::text,$2::text) RETURNING id`, + [card, code], + ) + ).rows[0].id, + ); + } + for (const [index, code] of [ + 'ExtraSmall', + 'Budget', + 'Small', + 'Big', + 'PremiumSmall', + 'PremiumBig', + ].entries()) { + const type = ( + await db.query<{ id: string }>( + `INSERT INTO atlas.logistics_product_types(rate_card_id,code,name,merchandise_currency_code,min_merchandise_value,max_merchandise_value,min_actual_weight_kg,max_actual_weight_kg,billing_basis) + VALUES($1,$2::text,$2::text,'CNY',0,10000,0.001,30,'actual_only') RETURNING id`, + [card, code], + ) + ).rows[0].id; + for (const method of methods.slice(0, [3, 3, 3, 2, 3, 2][index])) + await db.query( + `INSERT INTO atlas.logistics_rate_rules(product_type_id,transport_method_id,fixed_fee_cny,per_billable_kg_fee_cny,status) + VALUES($1,$2,3.12,10,'draft')`, + [type, method], + ); + } + }); + + afterAll(async () => { + if (migrator) { + if (card) + await migrator.query( + `DELETE FROM atlas.logistics_rate_card_reviews WHERE rate_card_id=$1`, + [card], + ); + if (user) + await migrator.query( + `DELETE FROM atlas.system_admin_grants WHERE user_id=$1`, + [user], + ); + } + if (db) { + if (card) + await db.query( + `DELETE FROM atlas.logistics_rate_cards WHERE id=$1`, + [card], + ); + if (user) { + await db.query( + `DELETE FROM atlas.workspace_memberships WHERE user_id=$1`, + [user], + ); + await db.query(`DELETE FROM atlas.workspaces WHERE id=$1`, [ + workspace, + ]); + await db.query(`DELETE FROM atlas.users WHERE id=$1`, [user]); + } + } + if (migrator) await migrator.end(); + if (app) await app.close(); + }); + + it('仅系统管理员可读取和启用;六类账单复算、重放与过期版本保护', async () => { + assert.equal( + ( + await request( + `/system/pricing/rate-cards?platform_id=${platform}`, + ) + ).status, + 403, + ); + await migrator.query( + `INSERT INTO atlas.system_admin_grants(user_id) VALUES($1)`, + [user], + ); + const listed = await request( + `/system/pricing/rate-cards?platform_id=${platform}`, + ); + assert.equal(listed.status, 200, JSON.stringify(listed.body)); + const item = listed.body.data.find((row: any) => row.id === card); + assert.equal(item.status, 'draft'); + assert.equal(item.types.length, 6); + assert.equal(item.rules.length, 16); + const input = { + rules_hash: item.rules_hash, + source_reference: 'CEL 真实账单 QA 批次-2026-09', + review_note: + '逐类型核对实际包装、货值、计费重量与物流账单费用;本测试版本仅在未来生效。', + effective_from: new Date(Date.now() + 86400000).toISOString(), + samples: item.types.map((type: any, index: number) => ({ + rule_id: item.rules.find( + (rule: any) => rule.product_type_id === type.id, + ).id, + invoice_reference: `QA-INVOICE-${index + 1}`, + merchandise_value_cny: '100', + shipping_weight_g: '1000', + length_mm: '100', + width_mm: '100', + height_mm: '100', + billed_logistics_cny: '13.12', + })), + }; + const key = randomUUID(); + const mismatch = structuredClone(input); + mismatch.samples[0].billed_logistics_cny = '12.00'; + const rejected = await request( + `/system/pricing/rate-cards/${card}/reviews`, + 'POST', + mismatch, + randomUUID(), + ); + assert.equal(rejected.status, 422, JSON.stringify(rejected.body)); + const repeatedInvoice = structuredClone(input); + repeatedInvoice.samples[1].invoice_reference = + repeatedInvoice.samples[0].invoice_reference; + assert.equal( + ( + await request( + `/system/pricing/rate-cards/${card}/reviews`, + 'POST', + repeatedInvoice, + randomUUID(), + ) + ).status, + 422, + ); + assert.equal( + (await request(`/system/pricing/rate-cards/${card}`)).body.data + .status, + 'draft', + ); + const [accepted, concurrentReplay] = await Promise.all([ + request( + `/system/pricing/rate-cards/${card}/reviews`, + 'POST', + input, + key, + ), + request( + `/system/pricing/rate-cards/${card}/reviews`, + 'POST', + input, + key, + ), + ]); + assert.equal(accepted.status, 201, JSON.stringify(accepted.body)); + assert.equal( + concurrentReplay.status, + 201, + JSON.stringify(concurrentReplay.body), + ); + assert.equal(accepted.body.data.status, 'active'); + assert.equal(accepted.body.data.review.samples.length, 6); + const replay = await request( + `/system/pricing/rate-cards/${card}/reviews`, + 'POST', + input, + key, + ); + assert.equal(replay.status, 201, JSON.stringify(replay.body)); + assert.equal( + ( + await request( + `/system/pricing/rate-cards/${card}/reviews`, + 'POST', + mismatch, + key, + ) + ).status, + 409, + ); + assert.equal( + ( + await request( + `/system/pricing/rate-cards/${card}/reviews`, + 'POST', + input, + randomUUID(), + ) + ).status, + 409, + ); + const activeRuleCount = await db.query<{ count: string }>( + `SELECT count(*) FROM atlas.logistics_rate_rules r JOIN atlas.logistics_product_types t ON t.id=r.product_type_id WHERE t.rate_card_id=$1 AND r.status='active'`, + [card], + ); + assert.equal(Number(activeRuleCount.rows[0].count), 16); + await assert.rejects(() => + db.query( + `UPDATE atlas.logistics_rate_rules SET fixed_fee_cny=4 WHERE id=$1`, + [item.rules[0].id], + ), + ); + await assert.rejects(() => + db.query( + `UPDATE atlas.logistics_rate_cards SET source_name='错误改写' WHERE id=$1`, + [card], + ), + ); + const draftCard = await db.query<{ id: string }>( + `SELECT id FROM atlas.logistics_rate_cards WHERE version='cel-rfbs-review-2026-09' LIMIT 1`, + ); + await assert.rejects(() => + db.query( + `UPDATE atlas.logistics_product_types SET rate_card_id=$1 WHERE id=$2`, + [draftCard.rows[0].id, item.types[0].id], + ), + ); + }); +}); diff --git a/apps/api/test/pricing-draft-mode.unit.test.ts b/apps/api/test/pricing-draft-mode.unit.test.ts new file mode 100644 index 0000000..fea95d9 --- /dev/null +++ b/apps/api/test/pricing-draft-mode.unit.test.ts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'vitest'; +import { draftPricingEnabled } from '../src/config/app-config.module.js'; + +describe('草稿费率开发模式边界', () => { + it('开发环境默认开放,可显式关闭;测试环境需显式开启', () => { + assert.equal(draftPricingEnabled('development', 'auto'), true); + assert.equal(draftPricingEnabled('development', 'false'), false); + assert.equal(draftPricingEnabled('test', 'auto'), false); + assert.equal(draftPricingEnabled('test', 'true'), true); + }); + + it('生产环境即使误设开关也不能使用草稿费率', () => { + assert.equal(draftPricingEnabled('production', 'true'), false); + assert.equal(draftPricingEnabled('production', 'auto'), false); + }); +}); diff --git a/apps/api/test/pricing-engine.unit.test.ts b/apps/api/test/pricing-engine.unit.test.ts new file mode 100644 index 0000000..4d0ad21 --- /dev/null +++ b/apps/api/test/pricing-engine.unit.test.ts @@ -0,0 +1,243 @@ +import { describe, it, expect } from 'vitest'; +import { + calculatePricing, + type LogisticsRule, + type CommissionRule, +} from '../src/pricing/pricing-engine.js'; +import { pricingPreviewRequestSchema, pricingInputKey } from '@atlas/contracts'; +const id = 'ad877f74-36d0-4ef9-a711-cd904b37912f'; +const input = () => + pricingPreviewRequestSchema.parse({ + store_id: id, + platform_product_category_id: id, + fulfillment: 'rfbs', + purchase_cost_cny: '28', + package: { + shipping_weight_g: '380', + length_mm: '220', + width_mm: '160', + height_mm: '90', + status: 'confirmed', + }, + pricing: { + rate_card_id: id, + transport_method_ids: [id], + default_transport_method_id: id, + domestic_shipping_and_label_fee_cny: '3', + ad_rate: '0.03', + last_mile_rate: '0.02', + withdrawal_rate: '0.01', + other_rate: '0.02', + desired_markup_rate: '0.5', + list_price_discount_rate: '0.2', + }, + }); +const rule: LogisticsRule = { + id, + product_type_id: id, + transport_method_id: id, + transport_name: '标准', + product_type_name: 'ExtraSmall', + fixed_fee_cny: '3.12', + per_billable_kg_fee_cny: '36.4', + min_charge_cny: null, + surcharge_rules: [], + merchandise_currency_code: 'CNY', + min_merchandise_value: null, + max_merchandise_value: '135', + min_value_inclusive: true, + max_value_inclusive: true, + min_actual_weight_kg: '0.001', + min_actual_weight_inclusive: true, + max_actual_weight_kg: '0.5', + max_sum_dimensions_cm: '90', + max_longest_side_cm: '60', + max_second_longest_side_cm: null, + billing_basis: 'actual_only', + volume_divisor: null, + volume_condition_sum_gt_cm: null, + volume_round_scale: null, + billing_round_scale: null, + max_volume_weight_kg: null, + volume_limit_round_scale: null, +}; +const commission: CommissionRule = { + id, + currency_code: 'RUB', + min_price: null, + max_price: null, + min_inclusive: true, + max_inclusive: true, + commission_rate: '0.12', + version: 'test', +}; +describe('十进制定价引擎', () => { + it('目标加价与明细一致,CNY / RUB 都按最终币种舍入', () => { + const c = calculatePricing(input(), 'CNY', '12.3', [rule], [commission]) + .options[0]; + expect(c.selling_price).toBe('77.44'); + expect(c.profit_cny).toBe('14.00'); + expect(c.list_price).toBe('96.80'); + expect(c.fees.logistics_cny).toBe('16.9520'); + const rub = calculatePricing( + input(), + 'RUB', + '12.3', + [rule], + [commission], + ).options[0]; + expect(rub.selling_price).toBe('952.52'); + expect(rub.can_confirm).toBe(true); + }); + it('固定售价保持原价并识别亏损', () => { + const i = input(); + i.pricing.mode = 'fixed_sale'; + i.pricing.selling_price = '10'; + const o = calculatePricing(i, 'CNY', '12.3', [rule], [commission]) + .options[0]; + expect(o.selling_price).toBe('10.00'); + expect(o.can_confirm).toBe(false); + expect(Number(o.profit_cny)).toBeLessThan(0); + }); + it('实际重、尺寸和开区间边界精确生效', () => { + const i = input(); + i.package.shipping_weight_g = '500'; + expect( + calculatePricing(i, 'CNY', '12.3', [rule], [commission]).options, + ).toHaveLength(1); + i.package.shipping_weight_g = '500.001'; + expect( + calculatePricing(i, 'CNY', '12.3', [rule], [commission]).options, + ).toHaveLength(0); + i.package.shipping_weight_g = '380'; + expect( + calculatePricing( + i, + 'CNY', + '12.3', + [ + { + ...rule, + min_actual_weight_kg: '0.380', + min_actual_weight_inclusive: false, + }, + ], + [commission], + ).options, + ).toHaveLength(0); + }); + it('先舍入售价再检查货值及佣金档,不跨档蒙混', () => { + const i = input(); + expect( + calculatePricing( + i, + 'CNY', + '12.3', + [{ ...rule, max_merchandise_value: '77.439' }], + [commission], + ).options, + ).toHaveLength(0); + expect( + calculatePricing( + i, + 'CNY', + '12.3', + [rule], + [{ ...commission, max_price: '952.51' }], + ).options, + ).toHaveLength(0); + }); + it('总费率达到 100% 拒绝,未知附加费不能漏算', () => { + const i = input(); + i.pricing.ad_rate = '0.9'; + expect( + calculatePricing(i, 'CNY', '12.3', [rule], [commission]).options, + ).toHaveLength(0); + expect( + calculatePricing( + input(), + 'CNY', + '12.3', + [{ ...rule, surcharge_rules: [{ unknown: true }] }], + [commission], + ).options, + ).toHaveLength(0); + }); + it('体积重按指定位数进位并检查进位后的上限', () => { + const i = input(); + i.package = { + shipping_weight_g: '5000', + length_mm: '500', + width_mm: '400', + height_mm: '301', + status: 'confirmed', + }; + const volume = { + ...rule, + max_actual_weight_kg: '30', + max_sum_dimensions_cm: '310', + billing_basis: 'max_actual_volume', + volume_divisor: '12000', + volume_round_scale: 3, + max_volume_weight_kg: '6', + volume_limit_round_scale: 0, + }; + expect( + calculatePricing( + i, + 'CNY', + '12.3', + [{ ...volume, max_merchandise_value: null }], + [commission], + ).options[0].billable_weight_kg, + ).toBe('5.017'); + expect( + calculatePricing( + i, + 'CNY', + '12.3', + [{ ...volume, max_volume_weight_kg: '5' }], + [commission], + ).options, + ).toHaveLength(0); + }); + it('默认运输失效时不偷偷选择其他方式', () => { + const i = input(); + i.pricing.default_transport_method_id = + '8d6df67c-1fa3-45b9-9766-d4f5c813b903'; + expect( + calculatePricing(i, 'CNY', '12.3', [rule], [commission]) + .recommended_option_id, + ).toBeNull(); + }); + it('格式化金额与 JSONB 字段重排不会使同一输入失效', () => { + const original = input(); + const reordered = JSON.parse( + JSON.stringify(original, (_key, value) => + value && typeof value === 'object' && !Array.isArray(value) + ? Object.fromEntries(Object.entries(value).reverse()) + : value, + ), + ); + reordered.purchase_cost_cny = '28.00'; + reordered.package.shipping_weight_g = '380.000'; + reordered.pricing.ad_rate = '0.030000'; + expect(pricingInputKey(original)).toBe(pricingInputKey(reordered)); + reordered.package.length_mm = '221'; + expect(pricingInputKey(original)).not.toBe(pricingInputKey(reordered)); + }); + it('显示利润舍入为零时,真实微亏仍禁止确认', () => { + const i = input(); + i.pricing.mode = 'fixed_sale'; + i.pricing.selling_price = '59.94'; + const option = calculatePricing( + i, + 'CNY', + '12.3', + [{ ...rule, fixed_fee_cny: '3.121' }], + [commission], + ).options[0]; + expect(option.profit_cny).toBe('-0.00'); + expect(option.can_confirm).toBe(false); + }); +}); diff --git a/apps/api/test/pricing.e2e.test.ts b/apps/api/test/pricing.e2e.test.ts new file mode 100644 index 0000000..59f42bc --- /dev/null +++ b/apps/api/test/pricing.e2e.test.ts @@ -0,0 +1,653 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import type { AddressInfo } from 'node:net'; +import type { Database } from '@atlas/database'; +import type { NestExpressApplication } from '@nestjs/platform-express'; +import type { + PricingPreviewRequest, + ProductEditorSnapshot, + SaveProductEditorDraftRequest, +} from '@atlas/contracts'; +import { pricingPreviewRequestSchema } from '@atlas/contracts'; +import { beforeAll, afterAll, it, describe } from 'vitest'; +import { DATABASE } from '../src/database/database.module.js'; +import { createApplication } from '../src/main.js'; +let app: NestExpressApplication, + db: Database, + base = '', + workspace = '', + user = '', + token = '', + platform = '', + store = '', + category = '', + product = '', + profile = '', + provider = '', + card = '', + transport = '', + variant = ''; +async function request( + path: string, + method = 'GET', + body?: unknown, + extra: Record = {}, +) { + const r = await fetch(base + '/api' + path, { + method, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + ...(method !== 'GET' ? { 'Idempotency-Key': randomUUID() } : {}), + ...extra, + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const data = (await r.json()) as any; + return { status: r.status, ...data }; +} +async function read(): Promise { + const r = await request(`/products/${product}/editor`, 'GET', undefined, { + 'X-Atlas-Platform-Id': platform, + }); + assert.equal(r.status, 200, JSON.stringify(r)); + return r.data; +} +function draft(s: ProductEditorSnapshot): SaveProductEditorDraftRequest { + return { + ai_source_material: s.source_material, + ai_source_overridden_fields: s.source_material_state.overridden_fields, + common: { + ...s.product.common, + default_physical_spec: s.product.default_physical_spec, + default_package_spec: { + shipping_weight_g: '380', + length_mm: '220', + width_mm: '160', + height_mm: '90', + status: 'confirmed', + }, + }, + profile: { + target_store_id: store, + fulfillment: 'rfbs', + title: s.profile.title, + description: s.profile.description, + tags: s.profile.tags, + platform_product_category_id: category, + category_source: 'manual', + }, + variants: s.variants.map((v) => ({ + id: v.id, + client_key: null, + display_name: v.display_name, + source_sku: v.source_sku, + purchase_cost_cny: '28.00', + specification_values: v.specification_values, + title: v.title, + tags: v.tags, + description: v.description, + physical_spec: v.physical_spec, + package_spec: v.package_spec, + offer_id: v.listing.offer_id, + media_asset_ids: [], + pricing_draft: v.listing.pricing_draft ?? null, + })), + }; +} +function quote( + variantId: string | null = variant, + clientKey: string | null = null, +): PricingPreviewRequest { + return pricingPreviewRequestSchema.parse({ + store_id: store, + platform_product_category_id: category, + fulfillment: 'rfbs', + binding: { + profile_id: profile, + variant_id: variantId, + client_key: clientKey, + }, + purchase_cost_cny: '28', + package: { + shipping_weight_g: '380', + length_mm: '220', + width_mm: '160', + height_mm: '90', + status: 'confirmed', + }, + pricing: { + rate_card_id: card, + transport_method_ids: [transport], + default_transport_method_id: transport, + domestic_shipping_and_label_fee_cny: '3', + ad_rate: '0.03', + last_mile_rate: '0.02', + withdrawal_rate: '0.01', + other_rate: '0.02', + desired_markup_rate: '0.5', + list_price_discount_rate: '0.2', + temporary_commission_rate: '0.12', + fx: { + mode: 'one_time_override', + rate: '12.3', + reason: '仅集成测试', + }, + }, + }); +} +async function save( + s: ProductEditorSnapshot, + d: SaveProductEditorDraftRequest, + key = randomUUID(), +) { + return request(`/products/${product}/editor-draft`, 'PUT', d, { + 'X-Atlas-Platform-Id': platform, + 'If-Match': `"${s.product.revision}:${s.profile.revision}"`, + 'Idempotency-Key': key, + }); +} +describe('SKU 试算与原子确认 HTTP / PostgreSQL', () => { + beforeAll(async () => { + Object.assign(process.env, { + NODE_ENV: 'test', + PRICING_ALLOW_DRAFT_RATES: 'true', + SWAGGER_ENABLED: 'false', + AUTH_REGISTER_IP_LIMIT: '100', + AUTH_REGISTER_EMAIL_LIMIT: '100', + }); + app = (await createApplication()).app; + db = app.get(DATABASE); + await app.listen(0, '127.0.0.1'); + base = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`; + const reg = await request('/auth/register', 'POST', { + email: `pricing-${randomUUID()}@example.test`, + password: 'pricing-test-password-123', + password_confirmation: 'pricing-test-password-123', + }); + assert.equal(reg.status, 201, JSON.stringify(reg)); + workspace = reg.data.workspace.id; + user = reg.data.user.id; + token = reg.data.access_token; + platform = (await request('/platforms')).data[0].id; + await db.workspaceTransaction(workspace, async (tx) => { + store = ( + await tx.query<{ id: string }>( + `INSERT INTO atlas.stores(workspace_id,platform_id,name,platform_client_id,currency_code,is_default,status,credential_status) VALUES($1,$2,'定价集成测试',$3,'CNY',true,'active','valid') RETURNING id`, + [workspace, platform, randomUUID()], + ) + ).rows[0].id; + category = ( + await tx.query<{ id: string }>( + `INSERT INTO atlas.platform_product_categories(platform_id,external_category_id,external_type_id,name,path) VALUES($1,$2,'test','测试类目','定价测试') RETURNING id`, + [platform, randomUUID()], + ) + ).rows[0].id; + await tx.query( + `INSERT INTO atlas.platform_catalog_resource_states(platform_id,scope,scope_key,category_id,last_success_at,is_complete) VALUES($1,'attributes','attributes:' || $2::uuid,$2,now(),true)`, + [platform, category], + ); + provider = ( + await tx.query<{ id: string }>( + `INSERT INTO atlas.logistics_providers(code,name) VALUES($1,'仅自动化测试') RETURNING id`, + [randomUUID()], + ) + ).rows[0].id; + card = ( + await tx.query<{ id: string }>( + `INSERT INTO atlas.logistics_rate_cards(provider_id,platform_id,fulfillment,version,source_name,status,effective_from,reviewed_at,reviewed_by_user_id) VALUES($1,$2,'rfbs','test-1','测试规则,不用于业务','active',now()-interval '1 day',now(),$3) RETURNING id`, + [provider, platform, user], + ) + ).rows[0].id; + transport = ( + await tx.query<{ id: string }>( + `INSERT INTO atlas.logistics_transport_methods(rate_card_id,code,name) VALUES($1,'standard','标准测试') RETURNING id`, + [card], + ) + ).rows[0].id; + const type = ( + await tx.query<{ id: string }>( + `INSERT INTO atlas.logistics_product_types(rate_card_id,code,name,merchandise_currency_code,max_merchandise_value,max_actual_weight_kg,max_sum_dimensions_cm,max_longest_side_cm,billing_basis) VALUES($1,'ExtraSmall','小包测试','CNY',135,0.5,90,60,'actual_only') RETURNING id`, + [card], + ) + ).rows[0].id; + await tx.query( + `INSERT INTO atlas.logistics_rate_rules(product_type_id,transport_method_id,fixed_fee_cny,per_billable_kg_fee_cny) VALUES($1,$2,3.12,36.4)`, + [type, transport], + ); + }); + const collection = await request('/collection-items', 'POST', { + title: '定价集成测试商品', + }); + assert.equal(collection.status, 201); + product = collection.data.product_id; + const context = await request( + `/collection-items/${collection.data.collection_item.id}/editor-contexts`, + 'POST', + undefined, + { 'X-Atlas-Platform-Id': platform }, + ); + assert.equal(context.status, 201, JSON.stringify(context)); + profile = context.data.profile_id; + variant = (await read()).variants[0].id; + }); + afterAll(async () => { + if (db && workspace) { + await db.workspaceTransaction(workspace, async (tx) => { + // 仅清理本测试创建的租户,按外键依赖顺序删除。 + for (const table of [ + 'idempotency_requests', + 'pricing_previews', + 'product_variant_listing_media', + 'product_variant_platform_listings', + 'pricing_quote_options', + 'pricing_quotes', + 'pricing_policy_transport_methods', + 'pricing_policies', + 'product_unmapped_attributes', + 'product_attributes', + 'product_variants', + 'product_platform_profiles', + 'products', + 'collection_items', + 'outbox_events', + 'operations', + 'stores', + ]) + await tx.query( + `DELETE FROM atlas.${table} WHERE workspace_id=$1`, + [workspace], + ); + }); + if (card) + await db.query( + 'DELETE FROM atlas.logistics_rate_cards WHERE id=$1', + [card], + ); + if (provider) + await db.query( + 'DELETE FROM atlas.logistics_providers WHERE id=$1', + [provider], + ); + if (category) { + await db.query( + 'DELETE FROM atlas.platform_catalog_resource_states WHERE category_id=$1', + [category], + ); + await db.query( + 'DELETE FROM atlas.platform_product_categories WHERE id=$1', + [category], + ); + } + await db.query( + 'DELETE FROM atlas.workspace_memberships WHERE user_id=$1', + [user], + ); + await db.query('DELETE FROM atlas.workspaces WHERE id=$1', [ + workspace, + ]); + await db.query('DELETE FROM atlas.users WHERE id=$1', [user]); + } + if (app) await app.close(); + }); + it('目录只返回审核规则,预览幂等且独立试算不写商品', async () => { + const catalog = await request(`/pricing/catalog?store_id=${store}`); + assert.equal(catalog.status, 200, JSON.stringify(catalog)); + assert.ok(catalog.data.rate_cards.some((c: any) => c.id === card)); + const categories = await request( + `/pricing/categories?store_id=${store}&q=定价测试`, + ); + assert.equal(categories.status, 200, JSON.stringify(categories)); + const input = quote(), + key = randomUUID(), + a = await request('/pricing/quote-previews', 'POST', input, { + 'Idempotency-Key': key, + }), + b = await request('/pricing/quote-previews', 'POST', input, { + 'Idempotency-Key': key, + }); + assert.equal(a.status, 201, JSON.stringify(a)); + assert.deepEqual(b.data, a.data); + assert.equal(a.data.options[0].selling_price, '77.44'); + assert.equal((await read()).variants[0].listing.selling_price, null); + }); + it('原子确认、重放、重读与同输入再保存保持快照', async () => { + const s = await read(), + d = draft(s), + input = quote(), + p = await request('/pricing/quote-previews', 'POST', input); + d.variants[0].pricing_draft = input.pricing; + d.variants[0].pricing_selection = { + preview_id: p.data.preview_id, + option_id: p.data.recommended_option_id, + input_hash: p.data.input_hash, + }; + const key = randomUUID(), + saved = await save(s, d, key); + assert.equal(saved.status, 200, JSON.stringify(saved)); + assert.deepEqual((await save(s, d, key)).data, saved.data); + const after = await read(); + assert.equal(after.variants[0].listing.pricing_valid, true); + assert.equal( + after.variants[0].listing.pricing_result?.profit_cny, + '14.00', + ); + assert.equal(Number(after.variants[0].listing.selling_price), 77.44); + const again = await save(after, draft(after)); + assert.equal(again.status, 200, JSON.stringify(again)); + assert.equal((await read()).variants[0].listing.pricing_valid, true); + }); + it('输入/有效期变化返回 SKU 错误,整笔商品保存回滚;无报价允许保存', async () => { + const s = await read(), + d = draft(s), + input = quote(), + p = await request('/pricing/quote-previews', 'POST', input); + d.common.source_title = '不应被保存'; + d.variants[0].pricing_draft = input.pricing; + d.variants[0].purchase_cost_cny = '29'; + d.variants[0].pricing_selection = { + preview_id: p.data.preview_id, + option_id: p.data.recommended_option_id, + input_hash: p.data.input_hash, + }; + const failed = await save(s, d); + assert.equal(failed.status, 409, JSON.stringify(failed)); + assert.equal(failed.error.code, 'PRICING_PREVIEW_STALE'); + assert.equal((await read()).product.revision, s.product.revision); + d.variants[0].purchase_cost_cny = '28'; + await db.workspaceTransaction(workspace, (tx) => + tx.query( + `UPDATE atlas.pricing_previews SET expires_at=now()-interval '1 second' WHERE id=$1`, + [p.data.preview_id], + ), + ); + assert.equal((await save(s, d)).status, 409); + d.variants[0].pricing_selection = null; + d.variants[0].purchase_cost_cny = '29'; + assert.equal((await save(s, d)).status, 200); + assert.equal((await read()).variants[0].listing.pricing_valid, false); + }); + it('新 SKU client_key 与真实 ID 在同一事务映射', async () => { + const s = await read(), + d = draft(s), + input = quote(null, 'new-pricing'), + p = await request('/pricing/quote-previews', 'POST', input); + d.variants.push({ + ...d.variants[0], + id: null, + client_key: 'new-pricing', + display_name: '新报价 SKU', + pricing_draft: input.pricing, + pricing_selection: { + preview_id: p.data.preview_id, + option_id: p.data.recommended_option_id, + input_hash: p.data.input_hash, + }, + }); + const saved = await save(s, d); + assert.equal(saved.status, 200, JSON.stringify(saved)); + const after = await read(), + newId = saved.data.created_variants[0].id; + assert.equal( + after.variants.find((v) => v.id === newId)?.listing.pricing_valid, + true, + ); + }); + it('其他租户不能读取预览或使用门店;报价复合外键拒绝跨 SKU 引用', async () => { + const outsider = randomUUID(); + await db.workspaceTransaction(outsider, async (tx) => { + assert.equal( + ( + await tx.query( + 'SELECT id FROM atlas.pricing_previews WHERE store_id=$1', + [store], + ) + ).rowCount, + 0, + ); + assert.equal( + ( + await tx.query( + 'SELECT id FROM atlas.pricing_quotes WHERE store_id=$1', + [store], + ) + ).rowCount, + 0, + ); + }); + const s = await read(); + await assert.rejects(() => + db.workspaceTransaction(workspace, async (tx) => { + await tx.query( + `UPDATE atlas.product_variant_platform_listings SET pricing_quote_id=(SELECT pricing_quote_id FROM atlas.product_variant_platform_listings WHERE product_variant_id=$1) WHERE product_variant_id=$2`, + [s.variants[1].id, s.variants[0].id], + ); + }), + ); + }); + it('方案 CRUD、版本冲突、默认与归档不改变已确认报价', async () => { + const body = { + name: '测试默认方案', + parameters: { + ...quote().pricing, + fx: { mode: 'policy_fixed', rate: '12.3' }, + }, + allow_commission_override: true, + }; + const c = await request( + `/stores/${store}/pricing-policies`, + 'POST', + body, + ); + assert.equal(c.status, 201, JSON.stringify(c)); + const id = c.data.id; + assert.equal( + (await request(`/pricing-policies/${id}/set-default`, 'POST')) + .status, + 200, + ); + assert.equal( + ( + await request(`/pricing-policies/${id}`, 'PATCH', body, { + 'If-Match': '"1"', + }) + ).status, + 409, + ); + const before = await read(); + assert.equal( + (await request(`/pricing-policies/${id}`, 'DELETE')).status, + 200, + ); + assert.deepEqual( + (await read()).variants[1].listing.pricing_result, + before.variants[1].listing.pricing_result, + ); + }); + it('规则更新、独立预览和其他 SKU 预览不能被旧确认意图接受', async () => { + const state = await read(), + payload = draft(state), + input = quote(); + const preview = await request('/pricing/quote-previews', 'POST', input); + payload.variants[0].pricing_draft = input.pricing; + payload.variants[0].pricing_selection = { + preview_id: preview.data.preview_id, + option_id: preview.data.recommended_option_id, + input_hash: preview.data.input_hash, + }; + await db.query( + `UPDATE atlas.logistics_rate_cards SET version='test-2',updated_at=now() WHERE id=$1`, + [card], + ); + assert.equal( + (await save(state, payload)).error.code, + 'PRICING_PREVIEW_STALE', + ); + const standalone = await request('/pricing/quote-previews', 'POST', { + ...input, + binding: null, + }); + payload.variants[0].pricing_selection = { + preview_id: standalone.data.preview_id, + option_id: standalone.data.recommended_option_id, + input_hash: standalone.data.input_hash, + }; + assert.equal((await save(state, payload)).status, 409); + const other = await request('/pricing/quote-previews', 'POST', { + ...input, + binding: { ...input.binding, variant_id: state.variants[1].id }, + }); + payload.variants[0].pricing_selection = { + preview_id: other.data.preview_id, + option_id: other.data.recommended_option_id, + input_hash: other.data.input_hash, + }; + assert.equal((await save(state, payload)).status, 409); + assert.equal((await read()).product.revision, state.product.revision); + }); + it('确认快照不可更新;历史预览过期不使确认失效', async () => { + const before = await read(); + await assert.rejects(() => + db.workspaceTransaction(workspace, (tx) => + tx.query( + `UPDATE atlas.pricing_quotes SET purchase_cost_cny=0 WHERE workspace_id=$1 AND status='confirmed'`, + [workspace], + ), + ), + ); + await assert.rejects(() => + db.workspaceTransaction(workspace, (tx) => + tx.query( + `UPDATE atlas.pricing_quote_options SET profit_rub=0 WHERE workspace_id=$1`, + [workspace], + ), + ), + ); + await db.workspaceTransaction(workspace, (tx) => + tx.query( + `UPDATE atlas.pricing_previews SET expires_at=now()-interval '1 day' WHERE workspace_id=$1`, + [workspace], + ), + ); + assert.deepEqual( + (await read()).variants[1].listing, + before.variants[1].listing, + ); + }); + it('没有当前报价的草稿保存成功,但发布校验给出定价问题', async () => { + const state = await read(), + payload = draft(state); + payload.variants[0].purchase_cost_cny = '41.00'; + assert.equal((await save(state, payload)).status, 200); + const validation = await request( + `/product-platform-profiles/${profile}/validation`, + 'GET', + undefined, + { 'X-Atlas-Platform-Id': platform }, + ); + assert.equal(validation.status, 200, JSON.stringify(validation)); + assert.ok( + validation.data.variants[0].issues.some( + (i: { code: string }) => i.code === 'PRICING_REQUIRED', + ), + ); + }); + it('并发商品保存只有一个版本成功', async () => { + const state = await read(), + a = draft(state), + b = draft(state); + a.common.source_title = '并发 A'; + b.common.source_title = '并发 B'; + const results = await Promise.all([save(state, a), save(state, b)]); + assert.deepEqual(results.map((r) => r.status).sort(), [200, 409]); + }); + it('未审核费率、缺失正式佣金以及亏损候选不会变成发布报价', async () => { + await db.query( + `UPDATE atlas.logistics_rate_cards SET reviewed_at=NULL WHERE id=$1`, + [card], + ); + assert.equal( + (await request('/pricing/quote-previews', 'POST', quote())).error + .code, + 'PRICING_RULES_UNAVAILABLE', + ); + await db.query( + `UPDATE atlas.logistics_rate_cards SET reviewed_at=now() WHERE id=$1`, + [card], + ); + const input = quote(); + input.pricing.temporary_commission_rate = null; + assert.equal( + (await request('/pricing/quote-previews', 'POST', input)).error + .code, + 'PRICING_COMMISSION_UNAVAILABLE', + ); + input.pricing.temporary_commission_rate = '0.12'; + input.pricing.mode = 'fixed_sale'; + input.pricing.selling_price = '10'; + const p = await request('/pricing/quote-previews', 'POST', input); + assert.equal(p.status, 201); + assert.equal(p.data.options[0].can_confirm, false); + const state = await read(), + payload = draft(state); + payload.variants[0].pricing_draft = input.pricing; + payload.variants[0].pricing_selection = { + preview_id: p.data.preview_id, + option_id: p.data.options[0].id, + input_hash: p.data.input_hash, + }; + assert.equal((await save(state, payload)).status, 409); + }); + 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], + ); + await db.query( + `UPDATE atlas.logistics_rate_rules SET status='draft' WHERE product_type_id IN (SELECT id FROM atlas.logistics_product_types WHERE rate_card_id=$1)`, + [card], + ); + const catalog = await request(`/pricing/catalog?store_id=${store}`); + assert.equal(catalog.status, 200, JSON.stringify(catalog)); + assert.equal( + catalog.data.rate_cards.find((item: { id: string }) => item.id === card) + ?.development_only, + true, + ); + const input = quote(); + input.purchase_cost_cny = '41.00'; + const preview = await request('/pricing/quote-previews', 'POST', input); + assert.equal(preview.status, 201, JSON.stringify(preview)); + assert.equal(preview.data.context.development_only, true); + const state = await read(); + const payload = draft(state); + payload.variants[0].purchase_cost_cny = '41.00'; + payload.variants[0].pricing_draft = input.pricing; + payload.variants[0].pricing_selection = { + preview_id: preview.data.preview_id, + option_id: preview.data.recommended_option_id, + input_hash: preview.data.input_hash, + }; + const saved = await save(state, payload); + assert.equal(saved.status, 200, JSON.stringify(saved)); + const validation = await request( + `/product-platform-profiles/${profile}/validation`, + 'GET', + undefined, + { 'X-Atlas-Platform-Id': platform }, + ); + assert.equal(validation.status, 200, JSON.stringify(validation)); + assert.ok( + validation.data.variants[0].issues.some( + (issue: { code: string }) => + issue.code === 'PRICING_RATE_REVIEW_REQUIRED', + ), + ); + const rateCard = await db.query<{ status: string; reviewed_at: Date | null }>( + 'SELECT status,reviewed_at FROM atlas.logistics_rate_cards WHERE id=$1', + [card], + ); + assert.equal(rateCard.rows[0].status, 'draft'); + assert.equal(rateCard.rows[0].reviewed_at, null); + }); +}); diff --git a/apps/api/test/product-editor-processed-media.unit.test.ts b/apps/api/test/product-editor-processed-media.unit.test.ts index 7234782..ea0eafb 100644 --- a/apps/api/test/product-editor-processed-media.unit.test.ts +++ b/apps/api/test/product-editor-processed-media.unit.test.ts @@ -29,6 +29,7 @@ describe('SKU 图片处理结果的保存资格', () => { const service = new ProductEditorService( {} as ConstructorParameters[0], {} as ConstructorParameters[1], + { pricingAllowDraftRates: false } as ConstructorParameters[2], ) as unknown as { persistListingMedia: (tx: ReturnType, value: typeof input) => Promise; }; diff --git a/apps/web/src/components/pricing/PricingCalculator.tsx b/apps/web/src/components/pricing/PricingCalculator.tsx new file mode 100644 index 0000000..7c4574e --- /dev/null +++ b/apps/web/src/components/pricing/PricingCalculator.tsx @@ -0,0 +1,676 @@ +import { useEffect, useRef, useState } from 'react'; +import { + App, + Modal, + Alert, + Button, + Card, + Col, + Collapse, + Descriptions, + Empty, + Input, + InputNumber, + Radio, + Row, + Select, + Space, + Statistic, + Tag, + Typography, +} from 'antd'; +import type { + PricingCatalog, + PricingOption, + PricingParameters, + PricingPolicy, + PricingPreview, + PricingPreviewRequest, +} from '@atlas/contracts'; +import { pricingPreviewRequestSchema } from '@atlas/contracts'; +import { pricingApi } from '@/services/pricing'; +import { apiErrorMessage } from '@/services/api'; +import { percentToRate, rateToPercent } from './pricingPercentage'; + +export function defaultPricing(catalog: PricingCatalog): PricingParameters | null { + const policy = catalog.policies.find( + (p) => + p.is_default && catalog.rate_cards.some((card) => card.id === p.parameters.rate_card_id) + ); + if (policy) return structuredClone(policy.parameters); + const card = catalog.rate_cards[0]; + if (!card || !card.transports.length) return null; + return { + pricing_policy_id: null, + rate_card_id: card.id, + transport_method_ids: card.transports.map((t) => t.id), + default_transport_method_id: card.transports[0].id, + domestic_shipping_and_label_fee_cny: '0', + ad_rate: '0', + last_mile_rate: '0', + withdrawal_rate: '0', + other_rate: '0', + desired_markup_rate: '0.5', + list_price_discount_rate: '0', + mode: 'target_markup', + selling_price: null, + fx: { mode: 'system' }, + temporary_commission_rate: null, + }; +} +export function PricingParametersFields({ + value: p, + onChange, + catalog, + policyEditing = false, +}: { + policyEditing?: boolean; + value: PricingParameters; + onChange: (p: PricingParameters) => void; + catalog: PricingCatalog; +}) { + const patch = (v: Partial) => onChange({ ...p, ...v }); + const rates: Array<[keyof PricingParameters, string]> = [ + ['desired_markup_rate', '目标加价率'], + ['list_price_discount_rate', '划线折扣率'], + ['ad_rate', '广告费率'], + ['last_mile_rate', '尾程费率'], + ['withdrawal_rate', '提现费率'], + ['other_rate', '其他费率'], + ]; + return ( + +
+ 定价方案 + ({ + value: x.id, + label: `${x.name} · ${x.version}${x.development_only ? ' · 开发草稿' : ''}`, + }))} + onChange={(id) => { + const c = catalog.rate_cards.find((x) => x.id === id)!; + patch({ + 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, + }); + }} + /> +
+ + {rates.map(([key, label]) => ( + + {label}(%) + + aria-label={label} + style={{ width: '100%' }} + min="0" + max={key === 'desired_markup_rate' ? '999' : '99.99'} + step="1" + precision={2} + addonAfter="%" + stringMode + value={rateToPercent(p[key] as string)} + onChange={(v) => patch({ [key]: percentToRate(v ?? '0') })} + /> + + ))} + + + 目标加价率 50% 表示按采购成本增加 50% 的目标利润;预估利润以已配置费用为准。 + + + + + patch({ + fx: { ...p.fx, reason: e.target.value }, + }) + } + /> + )} + + )} + +
+ ), + }, + ]} + /> +
+ 默认运输方式 + setPolicyName(e.target.value)} + /> + + 保存费用、加价、折扣和运输偏好。采购价、包装、手动售价及临时佣金不保存。 + {input.pricing.fx.mode === 'one_time_override' + ? '本次汇率将保存为方案固定汇率。' + : ''} + + + + + + + {catalog.store.name} · {catalog.store.currency_code} + + + + {input.package.status !== 'confirmed' && ( + + setInput({ + ...input, + package: { + ...input.package, + status: 'confirmed', + }, + }) + } + > + 核对后确认包装 + + } + /> + )} + + {( + [ + ['shipping_weight_g', '重量 g'], + ['length_mm', '长 mm'], + ['width_mm', '宽 mm'], + ['height_mm', '高 mm'], + ] as const + ).map(([k, label]) => ( + + + + ))} + + + + setInput({ + ...input, + pricing: { + ...input.pricing, + mode: e.target.value, + selling_price: + e.target.value === 'target_markup' + ? null + : input.pricing.selling_price, + }, + }) + } + options={[ + { value: 'target_markup', label: '按目标算价' }, + { value: 'fixed_sale', label: '手动售价' }, + ]} + /> + {input.pricing.mode === 'fixed_sale' && ( + + aria-label="手动售价" + stringMode + min="0.01" + value={input.pricing.selling_price} + onChange={(v) => + setInput({ + ...input, + pricing: { ...input.pricing, selling_price: v }, + }) + } + placeholder={`销售价 ${catalog.store.currency_code}`} + /> + )} + setInput({ ...input, pricing })} + /> + + + + + {previous && ( + + )} + + 试算结果 {busy && 计算中} + + {error && } + {expired && ( + setRefresh((x) => x + 1)}> + 重新计算 + + } + /> + )} + {option ? ( + <> + + + + + + 划线价 {option.list_price} + + + + + + 占销售额{' '} + {rateToPercent(option.margin_rate)}% + + + + + {option.warnings.map((w) => ( + + ))} + ({ + key: k, + label: ( + { + logistics_cny: '跨境物流', + domestic_cny: '国内运费 / 贴单', + commission_cny: '平台佣金', + ad_cny: '广告', + last_mile_cny: '尾程', + withdrawal_cny: '提现', + other_cny: '其他', + } as Record + )[k], + children: v, + }))} + /> + + ) : ( + + )} + {preview && ( + <> + 运输方案比较 + setSelected(e.target.value)} + style={{ width: '100%' }} + > + + {preview.options.map((o) => ( + + {o.transport_name} · {o.product_type_name}  + {o.selling_price} {o.currency_code} 利润 ¥ + {o.profit_cny} + {o.id === preview.options[0]?.id && ( + 最低售价 + )} + {o.id === preview.recommended_option_id && ( + 方案默认 + )} + + ))} + + + {preview.unavailable_options.map((u) => ( + + { + catalog.rate_cards + .flatMap((c) => c.transports) + .find((t) => t.id === u.transport_method_id)?.name + } + :{u.reason} + + ))} + + 规则 {preview.context.rule_version} · 汇率{' '} + {preview.context.fx_rate}( + {( + { + system_rate: 'CBR 官方参考', + policy_fixed: '方案固定', + quote_override: '本次覆盖', + } as Record + )[preview.context.fx_origin] ?? preview.context.fx_origin} + ) + + {preview.context.warnings.map((w) => ( + + ))} + + )} + + + {onCancel && } + + {onApply && ( + + )} + + {onApply && ( + + 应用后仍需保存商品,才会确认报价。 + + )} + + + +
+ ); +} diff --git a/apps/web/src/components/pricing/SkuPricing.tsx b/apps/web/src/components/pricing/SkuPricing.tsx new file mode 100644 index 0000000..05a3138 --- /dev/null +++ b/apps/web/src/components/pricing/SkuPricing.tsx @@ -0,0 +1,447 @@ +import { useEffect, useRef, useState } from 'react'; +import { Button, Form, InputNumber, Modal, Space, Tag, Typography } from 'antd'; +import type { FormInstance } from 'antd'; +import type { + PricingCatalog, + PricingOption, + PricingParameters, + PricingPreview, + PricingPreviewRequest, + ProductEditorSnapshot, + SaveProductEditorDraftRequest, +} from '@atlas/contracts'; +import { pricingPreviewRequestSchema, pricingInputKey } from '@atlas/contracts'; +import { pricingApi } from '@/services/pricing'; +import { apiErrorMessage } from '@/services/api'; +import { defaultPricing, PricingCalculator } from './PricingCalculator'; +type Values = SaveProductEditorDraftRequest; +export function skuPricingInput( + values: Values, + index: number, + profileId: string, + parameters: PricingParameters +): PricingPreviewRequest { + const v = values.variants[index], + base = values.common.default_package_spec, + pkg = v.package_spec; + return { + store_id: values.profile.target_store_id, + platform_product_category_id: values.profile.platform_product_category_id ?? '', + fulfillment: values.profile.fulfillment as 'rfbs', + binding: { + profile_id: profileId, + variant_id: v.id, + client_key: v.id ? null : v.client_key, + }, + purchase_cost_cny: v.purchase_cost_cny ?? '', + package: { + shipping_weight_g: pkg.shipping_weight_g ?? base.shipping_weight_g ?? '', + length_mm: pkg.length_mm ?? base.length_mm ?? '', + width_mm: pkg.width_mm ?? base.width_mm ?? '', + height_mm: pkg.height_mm ?? base.height_mm ?? '', + status: (pkg.status ?? base.status) as 'confirmed', + }, + pricing: parameters, + }; +} +const keyFor = pricingInputKey; +export function SkuPricing({ + form, + index, + snapshot, + auto, + batch, + onChanged, +}: { + form: FormInstance; + index: number; + snapshot: ProductEditorSnapshot; + auto: boolean; + batch: number; + onChanged: () => void; +}) { + Form.useWatch('variants', form); + Form.useWatch('common', form); + Form.useWatch('profile', form); + const values = form.getFieldsValue(true) as Values, + v = values.variants[index], + original = snapshot.variants.find((x) => x.id === v?.id); + const [catalog, setCatalog] = useState(null), + [error, setError] = useState(''), + [busy, setBusy] = useState(false), + [result, setResult] = useState<{ + inputKey: string; + preview: PricingPreview; + option: PricingOption; + } | null>(null), + [modal, setModal] = useState(null); + const restoreInheritance = useRef(false); + const sequence = useRef(0), + controller = useRef(null), + lastRequest = useRef(''), + lastBatch = useRef(batch); + const store = values.profile?.target_store_id; + useEffect(() => { + let active = true; + setCatalog(null); + setError(''); + void pricingApi + .catalog(store) + .then((c) => { + if (active) setCatalog(c); + }) + .catch((e) => { + if (active) setError(apiErrorMessage(e)); + }); + return () => { + active = false; + }; + }, [store]); + useEffect( + () => () => { + controller.current?.abort(); + sequence.current++; + }, + [] + ); + const params = v?.pricing_draft ?? (catalog ? defaultPricing(catalog) : null); + const live = params && v ? skuPricingInput(values, index, snapshot.profile.id, params) : null; + const liveKey = live ? keyFor(live) : ''; + const liveRef = useRef(liveKey); + liveRef.current = liveKey; + const [now, setNow] = useState(Date.now()); + useEffect(() => { + if (!result) return; + const timer = setTimeout( + () => setNow(Date.now()), + Math.max(0, Date.parse(result.preview.expires_at) - Date.now()) + 20 + ); + return () => clearTimeout(timer); + }, [result]); + const resultValid = + (!!v?.pricing_selection || result?.option.can_confirm === false) && + result?.inputKey === liveKey && + Date.parse(result.preview.expires_at) > now; + const savedInput = original?.listing.pricing_draft + ? { + store_id: snapshot.profile.target_store.id, + platform_product_category_id: snapshot.profile.category.id ?? '', + fulfillment: snapshot.profile.fulfillment as 'rfbs', + binding: { + profile_id: snapshot.profile.id, + variant_id: original.id, + client_key: null, + }, + purchase_cost_cny: original.purchase_cost_cny ?? '', + package: { + ...original.effective_package_spec, + status: original.effective_package_spec.status as 'confirmed', + }, + pricing: original.listing.pricing_draft, + } + : null; + const confirmed = + !!original?.listing.pricing_valid && + !!savedInput && + liveKey === keyFor(savedInput as PricingPreviewRequest) && + !v?.pricing_selection; + const option = resultValid + ? result!.option + : confirmed + ? original?.listing.pricing_result + : null; + const setParameters = (p: PricingParameters) => { + form.setFieldValue(['variants', index, 'pricing_draft'], p); + onChanged(); + }; + function apply( + input: PricingPreviewRequest, + preview: PricingPreview, + optionId: string, + copyInputs: boolean + ) { + if (copyInputs) { + form.setFieldValue(['variants', index, 'purchase_cost_cny'], input.purchase_cost_cny); + if ( + restoreInheritance.current && + JSON.stringify(input.package) === JSON.stringify(modal?.package) + ) { + form.setFieldValue(['variants', index, 'package_spec'], { + shipping_weight_g: null, + length_mm: null, + width_mm: null, + height_mm: null, + status: null, + }); + } else if (JSON.stringify(input.package) !== JSON.stringify(modal?.package)) { + form.setFieldValue(['variants', index, 'package_spec'], input.package); + } + } + setParameters(input.pricing); + form.setFieldValue(['variants', index, 'pricing_selection'], { + preview_id: preview.preview_id, + option_id: optionId, + input_hash: preview.input_hash, + }); + setResult({ + inputKey: keyFor(input), + preview, + option: preview.options.find((o) => o.id === optionId)!, + }); + setError(''); + onChanged(); + } + async function calculate(force = false, override?: PricingParameters) { + if (!catalog || !v || original?.listing.status === 'imported') return; + const current = form.getFieldValue(['variants', index]) as Values['variants'][number]; + const parameters = override ?? current.pricing_draft ?? defaultPricing(catalog); + if (!parameters) { + setError('没有已审核物流费率,请先配置费率'); + return; + } + const input = skuPricingInput( + form.getFieldsValue(true), + index, + snapshot.profile.id, + parameters + ), + inputKey = keyFor(input); + if (!force && ((lastRequest.current === inputKey && resultValid) || confirmed)) return; + const parsed = pricingPreviewRequestSchema.safeParse(input); + if (!parsed.success) { + setError('请补齐采购价、类目,并确认有效包装重量和尺寸'); + return; + } + setParameters(parameters); + lastRequest.current = inputKey; + controller.current?.abort(); + controller.current = new AbortController(); + const seq = ++sequence.current; + setBusy(true); + setError(''); + try { + const p = await pricingApi.preview(parsed.data, controller.current.signal); + if (seq !== sequence.current || liveRef.current !== inputKey) return; + const recommended = p.options.find((o) => o.id === p.recommended_option_id); + if (!recommended) { + setError( + p.unavailable_options.map((x) => x.reason).join(';') || + '默认运输方式不可用,请在详情中选择' + ); + return; + } + if (!recommended.can_confirm) { + setResult({ inputKey, preview: p, option: recommended }); + setError('当前售价亏损,不能确认,请调整售价'); + return; + } + apply(input, p, recommended.id, false); + } catch (e) { + if (seq === sequence.current && !controller.current.signal.aborted) + setError(apiErrorMessage(e)); + } finally { + if (seq === sequence.current) setBusy(false); + } + } + // 包装和门店上下文变化自动重算;采购价与手动售价仅在失焦/Enter 时提交。 + const contextKey = live + ? JSON.stringify([ + live.store_id, + live.platform_product_category_id, + live.fulfillment, + live.package, + ]) + : ''; + const previousContext = useRef(contextKey); + useEffect(() => { + if (previousContext.current === contextKey) return; + previousContext.current = contextKey; + sequence.current++; + controller.current?.abort(); + setBusy(false); + if (auto && live && result?.inputKey !== liveKey) { + const timer = setTimeout(() => void calculate(true), 400); + return () => clearTimeout(timer); + } + }, [contextKey, auto]); + useEffect(() => { + if (batch === lastBatch.current) return; + lastBatch.current = batch; + void calculate(true); + }, [batch]); + if (!v) return null; + const imported = original?.listing.status === 'imported'; + return ( +
+
+ 采购价(CNY) + + aria-label={`SKU ${index + 1} 采购价`} + min="0" + stringMode + precision={2} + value={v.purchase_cost_cny} + onChange={(value) => { + form.setFieldValue(['variants', index, 'purchase_cost_cny'], value); + onChanged(); + }} + onBlur={() => { + if (auto && !imported) void calculate(); + }} + onPressEnter={(event) => { + event.preventDefault(); + void calculate(true); + }} + /> +
+
+ + 销售价({catalog?.store.currency_code ?? original?.listing.currency_code ?? '—'} + ) + + + aria-label={`SKU ${index + 1} 销售价`} + min="0.01" + stringMode + precision={2} + disabled={imported || !params} + value={ + params?.mode === 'fixed_sale' + ? params.selling_price + : (option?.selling_price ?? original?.listing.selling_price) + } + onChange={(value) => { + if (params) + setParameters({ ...params, mode: 'fixed_sale', selling_price: value }); + }} + onBlur={() => { + if (auto) void calculate(true); + }} + onPressEnter={(event) => { + event.preventDefault(); + void calculate(true); + }} + /> +
+
+ 预估净利润(CNY) + + {option?.profit_cny ?? '—'} + +
+ {!imported && ( + + + + + )} +
+ + + {busy + ? '计算中' + : confirmed + ? '已确认' + : resultValid + ? result?.option.can_confirm === false + ? '亏损 · 请调整售价' + : '已试算 · 待保存' + : v.pricing_selection + ? '输入已变化 · 需重新试算' + : '待定价'} + + {catalog?.rate_cards.find((card) => card.id === params?.rate_card_id) + ?.development_only && 开发草稿费率 · 不可上架} + {params?.mode === 'fixed_sale' && ( + <> + 手动售价 + + + )} + + {catalog?.policies.find((p) => p.id === params?.pricing_policy_id)?.name ?? + '本次参数'} + {option ? ` · ${option.transport_name}` : ''} + + + {error && ( +
+ {error} +
+ )} +
+ setModal(null)} + > + {modal && catalog && ( + <> + + 包装沿用当前有效规格,修改后只覆盖本 SKU。取消不会改变草稿。 + + + setModal(null)} + onApply={(input, p, id) => { + apply(input, p, id, true); + setModal(null); + }} + /> + + )} + +
+ ); +} diff --git a/apps/web/src/components/pricing/pricingPercentage.ts b/apps/web/src/components/pricing/pricingPercentage.ts new file mode 100644 index 0000000..5bc03bf --- /dev/null +++ b/apps/web/src/components/pricing/pricingPercentage.ts @@ -0,0 +1,25 @@ +/** 界面使用百分数,API 保留十进制比例字符串;移位换算避免浮点误差。 */ +export function rateToPercent(rate: string): string { + const negative = rate.startsWith('-'); + const [integer, fraction = ''] = (negative ? rate.slice(1) : rate).split('.'); + const digits = integer + fraction.padEnd(2, '0'); + const point = integer.length + 2; + const whole = digits.slice(0, point).replace(/^0+(?=\d)/, ''); + const decimals = digits.slice(point); + const hundredths = + BigInt(whole) * 100n + + BigInt(decimals.padEnd(2, '0').slice(0, 2)) + + (Number(decimals[2] ?? '0') >= 5 ? 1n : 0n); + return `${negative && hundredths > 0n ? '-' : ''}${hundredths / 100n}.${String(hundredths % 100n).padStart(2, '0')}`; +} + +export function percentToRate(percent: string): string { + const [integer, fraction = ''] = percent.split('.'); + const hundredths = + BigInt(integer || '0') * 100n + + BigInt(fraction.padEnd(2, '0').slice(0, 2)) + + (Number(fraction[2] ?? '0') >= 5 ? 1n : 0n); + const whole = String(hundredths / 10000n); + const decimals = String(hundredths % 10000n).padStart(4, '0').replace(/0+$/, ''); + return decimals ? `${whole}.${decimals}` : whole; +} diff --git a/apps/web/src/layouts/SystemAdminLayout.tsx b/apps/web/src/layouts/SystemAdminLayout.tsx index 32915d2..5f4cf70 100644 --- a/apps/web/src/layouts/SystemAdminLayout.tsx +++ b/apps/web/src/layouts/SystemAdminLayout.tsx @@ -1,5 +1,10 @@ import { Button, Layout, Menu, Result, Spin } from 'antd'; -import { AppstoreOutlined, ArrowLeftOutlined, DatabaseOutlined } from '@ant-design/icons'; +import { + AppstoreOutlined, + ArrowLeftOutlined, + CalculatorOutlined, + DatabaseOutlined, +} from '@ant-design/icons'; import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { useSystemAdminAccess } from '@/services/useSystemAdminAccess'; import './SystemAdminLayout.css'; @@ -12,12 +17,21 @@ export default function SystemAdminLayout() { const location = useLocation(); const navigate = useNavigate(); if (access === 'checking') { - return
; + return ( +
+ +
+ ); } if (access === 'denied') { - return navigate('/online-products')}>返回工作台 - } />; + return ( + navigate('/online-products')}>返回工作台} + /> + ); } return ( @@ -28,24 +42,45 @@ export default function SystemAdminLayout() { , - label: '平台类目与属性', - }]} + selectedKeys={[ + location.pathname.startsWith('/system/platform-catalog') + ? '/system/platform-catalog' + : location.pathname, + ]} + items={[ + { + key: '/system/platform-catalog', + icon: , + label: '平台类目与属性', + }, + { + key: '/system/pricing-rates', + icon: , + label: '物流费率审核', + }, + ]} onClick={({ key }) => navigate(key)} />
- 系统管理 / 平台类目与属性 -
- + + +
); diff --git a/apps/web/src/pages/pricing/PricingPage.tsx b/apps/web/src/pages/pricing/PricingPage.tsx index 928dd8a..5765af2 100644 --- a/apps/web/src/pages/pricing/PricingPage.tsx +++ b/apps/web/src/pages/pricing/PricingPage.tsx @@ -1,8 +1,451 @@ -import PagePlaceholder from '@/components/PagePlaceholder'; - -/** 定价工具:独立试算与物流计价规则(业务模块尚未实现) */ -const PricingPage = () => ( - -); - -export default PricingPage; +import { useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Alert, + App, + Button, + Card, + Empty, + Input, + Modal, + Popconfirm, + Select, + Space, + Switch, + Table, + Tabs, + Tag, + Tooltip, + Typography, +} from 'antd'; +import type { + PricingCatalog, + PricingCategory, + PricingPolicy, + PricingPolicyRequest, + Store, +} from '@atlas/contracts'; +import { + PricingCalculator, + PricingParametersFields, + defaultPricing, +} from '@/components/pricing/PricingCalculator'; +import { pricingApi } from '@/services/pricing'; +import { storesApi } from '@/services/stores'; +import { usePlatform } from '@/services/platform'; +import { apiErrorMessage } from '@/services/api'; +import { useSystemAdminAccess } from '@/services/useSystemAdminAccess'; +import { rateToPercent } from '@/components/pricing/pricingPercentage'; +export default function PricingPage() { + const { currentPlatform, context } = usePlatform(), + { message } = App.useApp(); + const navigate = useNavigate(); + const adminAccess = useSystemAdminAccess(); + const [stores, setStores] = useState([]), + [store, setStore] = useState(null), + [catalog, setCatalog] = useState(null), + [error, setError] = useState(''), + [categories, setCategories] = useState([]), + [category, setCategory] = useState(null), + [query, setQuery] = useState(''), + [editing, setEditing] = useState<{ + policy: PricingPolicy | null; + input: PricingPolicyRequest; + } | null>(null), + [saving, setSaving] = useState(false); + const request = useRef(0); + useEffect(() => { + let active = true; + setStore(null); + setCatalog(null); + if (currentPlatform) + void (async () => { + let cursor: string | undefined; + const all: Store[] = []; + do { + const r = await storesApi.list(currentPlatform.id, cursor); + all.push(...r.data); + cursor = r.meta.next_cursor ?? undefined; + } while (cursor); + if (active) { + setStores(all); + setStore( + all.find((s) => s.id === context?.default_store?.id)?.id ?? + all[0]?.id ?? + null + ); + } + })().catch((e) => { + if (active) setError(apiErrorMessage(e)); + }); + return () => { + active = false; + }; + }, [currentPlatform?.id]); + const reload = async () => { + if (store) setCatalog(await pricingApi.catalog(store)); + }; + useEffect(() => { + let active = true; + setCatalog(null); + setCategory(null); + setError(''); + if (store) + void pricingApi + .catalog(store) + .then((c) => { + if (active) setCatalog(c); + }) + .catch((e) => { + if (active) setError(apiErrorMessage(e)); + }); + return () => { + active = false; + }; + }, [store]); + useEffect(() => { + const seq = ++request.current; + setCategories([]); + if (!store) return; + const timer = setTimeout( + () => + void pricingApi + .categories(store, query) + .then((r) => { + if (request.current === seq) setCategories(r); + }) + .catch((e) => { + if (request.current === seq) setError(apiErrorMessage(e)); + }), + 250 + ); + return () => clearTimeout(timer); + }, [store, query]); + const parameters = catalog ? defaultPricing(catalog) : null; + const rateCardNotice = + catalog && !parameters ? ( + + {catalog.pending_rate_cards.length > 0 && ( + + 待复核: + {catalog.pending_rate_cards + .map((card) => `${card.name}(${card.version})`) + .join('、')} + + )} + + 待复核费率不能用于正式报价。系统管理员须在「系统管理 → + 物流费率审核」录入真实账单样本,复算通过后按生效日期启用。 + + {adminAccess === 'allowed' && ( + + )} + + } + /> + ) : null; + const mutate = async (fn: () => Promise) => { + try { + await fn(); + await reload(); + } catch (e) { + message.error(apiErrorMessage(e)); + } + }; + return ( + +
+ 定价工具 + + 按门店方案试算,比较物流费用与预估净利润。 + +
+ ({ + value: c.id, + label: c.path || c.name, + }))} + onChange={setCategory} + /> + {!parameters ? ( + rateCardNotice + ) : !category ? ( + + ) : ( + + setCatalog( + (current) => + current && { + ...current, + policies: [ + ...current.policies, + policy, + ], + } + ) + } + initial={{ + store_id: store!, + platform_product_category_id: category, + fulfillment: 'rfbs', + binding: null, + purchase_cost_cny: '', + package: { + shipping_weight_g: '', + length_mm: '', + width_mm: '', + height_mm: '', + status: 'confirmed', + }, + pricing: parameters, + }} + /> + )} +
+ + ), + }, + { + key: 'policies', + label: '定价方案', + children: ( + + + + } + > + {rateCardNotice} + ( + <> + {name}{' '} + {p.is_default && ( + 默认 + )} + + ), + }, + { + title: '目标加价率', + render: (_, p) => + `${rateToPercent(p.parameters.desired_markup_rate)}%`, + }, + { + title: '汇率', + render: (_, p) => + p.parameters.fx.mode === 'policy_fixed' + ? p.parameters.fx.rate + : 'CBR 系统汇率', + }, + { + title: '操作', + render: (_, p) => ( + + + {!p.is_default && ( + + )} + + mutate(() => + pricingApi.archivePolicy(p.id) + ) + } + > + + + + ), + }, + ]} + /> + + ), + }, + ]} + /> + )} + setEditing(null)} + onOk={async () => { + if (!editing || !store) return; + setSaving(true); + try { + if (editing.policy) + await pricingApi.updatePolicy(editing.policy, editing.input); + else await pricingApi.createPolicy(store, editing.input); + await reload(); + setEditing(null); + message.success('方案已保存'); + } catch (e) { + message.error(apiErrorMessage(e)); + } finally { + setSaving(false); + } + }} + > + {editing && catalog && ( + + + setEditing({ + ...editing, + input: { ...editing.input, name: e.target.value }, + }) + } + /> + + setEditing({ ...editing, input: { ...editing.input, parameters } }) + } + /> + + + setEditing({ + ...editing, + input: { ...editing.input, allow_commission_override: v }, + }) + } + /> + 缺少正式规则时允许临时佣金 + + + 方案只保存费用、利润、汇率和运输偏好,不包含采购价与包装。修改不影响已确认报价。 + + + )} + + + ); +} diff --git a/apps/web/src/pages/products/ProductEditPage.tsx b/apps/web/src/pages/products/ProductEditPage.tsx index 815d450..90033ca 100644 --- a/apps/web/src/pages/products/ProductEditPage.tsx +++ b/apps/web/src/pages/products/ProductEditPage.tsx @@ -148,6 +148,8 @@ function formValues(snapshot: ProductEditorSnapshot): EditorFormValues { source_sku: variant.source_sku, specification_values: variant.specification_values, purchase_cost_cny: variant.purchase_cost_cny, + pricing_draft: variant.listing.pricing_draft ?? null, + pricing_selection: null, title: variant.title, tags: variant.tags, description: variant.description, @@ -801,6 +803,7 @@ const ProductEditPage = () => { const useVariantTitle = () => setUsesVariantTitle(true); /** 保存草稿:携带快照版本号做乐观锁;来源更新/版本冲突时重新加载合并结果。 */ const save = async (values: EditorFormValues) => { + values = { ...values, ...form.getFieldsValue(true) }; if (!productId || !currentPlatform || !snapshot) return false; setSaving(true); try { @@ -824,7 +827,22 @@ const ProductEditPage = () => { await load(); return true; } catch (error) { - if (apiErrorCode(error) === 'SOURCE_SNAPSHOT_CHANGED') { + if (apiErrorCode(error) === 'PRICING_PREVIEW_STALE') { + Modal.confirm({ + title: '报价已失效', + content: apiErrorMessage(error), + okText: '仅保存草稿,稍后定价', + cancelText: '返回重新试算', + onOk: async () => { + const draft = { + ...values, + variants: values.variants.map((v) => ({ ...v, pricing_selection: null })), + }; + form.setFieldsValue(draft); + await save(draft); + }, + }); + } else if (apiErrorCode(error) === 'SOURCE_SNAPSHOT_CHANGED') { message.warning('采集来源已更新,已重新加载合并结果,请确认后再次保存'); await load(); } else if (apiErrorCode(error) === 'VERSION_CONFLICT') { diff --git a/apps/web/src/pages/products/components/ProductVariantSection.tsx b/apps/web/src/pages/products/components/ProductVariantSection.tsx index cbf045f..0adf323 100644 --- a/apps/web/src/pages/products/components/ProductVariantSection.tsx +++ b/apps/web/src/pages/products/components/ProductVariantSection.tsx @@ -1,3 +1,5 @@ +import { SkuPricing } from '@/components/pricing/SkuPricing'; +import { Switch, Space as PricingSpace } from 'antd'; import { useEffect, useRef, useState } from 'react'; import { Button, @@ -166,6 +168,8 @@ export function ProductVariantSection({ onCompareCopy, onImagesChanged, }: Props) { + const [pricingAuto, setPricingAuto] = useState(() => localStorage.getItem('atlas.pricing.auto') !== 'false'); + const [pricingBatch, setPricingBatch] = useState(0); // 实时监听变体草稿,驱动已选图片和主图回显。 const draftVariants = (Form.useWatch('variants', form) ?? []) as VariantValues[]; // 展开状态按 Form.List 的 field.key(而非下标)记录,增删/复制 SKU 后不会错位。 @@ -220,7 +224,7 @@ export function ProductVariantSection({ number="03" title="变体设置" description="卡片用于快速核对与填写;展开后可维护图片和该 SKU 的包装覆盖。" - extra={{snapshot.variants.length} 个 SKU} + extra={自动试算{setPricingAuto(value);localStorage.setItem('atlas.pricing.auto',String(value));}}/>{snapshot.variants.length} 个 SKU} /> {(fields, { add, remove }) => { @@ -316,7 +320,8 @@ export function ProductVariantSection({ ...variantValue, id: null, client_key: crypto.randomUUID(), - copy_from_variant_id: original?.id ?? null, + copy_from_variant_id: null, + pricing_selection: null, display_name: variantValue.display_name ? `${variantValue.display_name} 副本` : 'SKU 副本', @@ -423,48 +428,7 @@ export function ProductVariantSection({ -
- 采购价(CNY) - - - min="0" - step="0.01" - precision={2} - stringMode - placeholder="—" - /> - -
-
- - 销售价( - {original?.listing.currency_code ?? '₽'} - ) - - - value={ - original?.listing.selling_price ?? - '' - } - placeholder="—" - readOnly - controls={false} - stringMode - /> -
-
- 净利润 - — -
+ + + {!card && !loading && } + {card && ( + <> + + {card.source_name} + + {card.status === 'active' ? '已启用' : '待审核'} + + + } + extra={ + card.status === 'draft' && ( + + ) + } + > + + + 版本:{card.version} 服务商:{card.provider_code} 生效时间: + {new Date(card.effective_from).toLocaleString()} + + + 产品类型 {card.types.length} 种,运价规则 {card.rules.length}{' '} + 条。已审核版本的规则变更应发布新版本。 + + {card.review && ( + + 审核人:{card.reviewer_name ?? '已注销用户'} 审核时间: + {new Date(card.review.reviewed_at).toLocaleString()} 证据: + {card.review.source_reference} + + )} + + + +
+ `${row.min_merchandise_value !== null && row.min_value_inclusive ? '[' : '('}${row.min_merchandise_value ?? '−∞'}, ${row.max_merchandise_value ?? '+∞'}${row.max_merchandise_value !== null && row.max_value_inclusive ? ']' : ')'}`, + }, + { + title: '实际重量 kg', + render: (_, row) => + `${row.min_actual_weight_inclusive ? '[' : '('}${row.min_actual_weight_kg}, ${row.max_actual_weight_kg ?? '不限'}]`, + }, + { + title: '尺寸上限 cm(三边和 / 最长 / 次长)', + render: (_, row) => + `${row.max_sum_dimensions_cm ?? '不限'} / ${row.max_longest_side_cm ?? '不限'} / ${row.max_second_longest_side_cm ?? '不限'}`, + }, + { + title: '体积重与进位', + render: (_, row) => + row.billing_basis === 'actual_only' + ? `仅实重;计费重向上取 ${row.billing_round_scale ?? '原值'} 位` + : `实重/体积取大;除数 ${row.volume_divisor};体积重向上取 ${row.volume_round_scale ?? '原值'} 位;计费重向上取 ${row.billing_round_scale ?? '原值'} 位`, + }, + { + title: '体积重资格上限 kg', + render: (_, row) => + row.max_volume_weight_kg + ? `${row.max_volume_weight_kg}(先向上取 ${row.volume_limit_round_scale ?? '原值'} 位)` + : '不限', + }, + ]} + /> + + +
row.min_charge_cny ?? '—', + }, + { + title: '状态', + render: (_, row) => + row.status === 'draft' ? '待审核' : '已启用', + }, + ]} + /> + + {card.review && ( + + {card.review.review_note} +
{ + const rule = card.rules.find( + (item) => item.id === sample.rule_id + ); + return rule + ? `${rule.product_type_code} · ${rule.transport_method_name}` + : '历史规则'; + }, + }, + { title: '货值 CNY', dataIndex: 'merchandise_value_cny' }, + { title: '重量 g', dataIndex: 'shipping_weight_g' }, + { + title: '包装 mm', + render: (_, sample) => + `${sample.length_mm} × ${sample.width_mm} × ${sample.height_mm}`, + }, + { title: '账单物流费 CNY', dataIndex: 'billed_logistics_cny' }, + ]} + /> + + )} + + )} + void approve()} + onCancel={() => setReviewOpen(false)} + > + + + setSourceReference(event.target.value)} + /> + setEffectiveFrom(event.target.value)} + /> + setReviewNote(event.target.value)} + /> + {card?.types.map((type) => { + const sample = samples[type.id]; + if (!sample) return null; + const fields: Array<[keyof Sample, string]> = [ + ['invoice_reference', '账单编号'], + ['merchandise_value_cny', '货值 CNY'], + ['shipping_weight_g', '重量 g'], + ['length_mm', '长 mm'], + ['width_mm', '宽 mm'], + ['height_mm', '高 mm'], + ['billed_logistics_cny', '账单物流费 CNY'], + ]; + return ( + + + + updateSample(type.id, { + [field]: event.target.value, + }) + } + /> + ))} + + + ); + })} + + + + ); +} diff --git a/apps/web/src/router/index.tsx b/apps/web/src/router/index.tsx index 576011b..6265679 100644 --- a/apps/web/src/router/index.tsx +++ b/apps/web/src/router/index.tsx @@ -18,6 +18,7 @@ import { PlatformProvider } from '@/services/platform'; import { useSession } from '@/services/session'; import SystemAdminLayout from '@/layouts/SystemAdminLayout'; import SystemCatalogPage from '@/pages/system/SystemCatalogPage'; +import SystemPricingRatesPage from '@/pages/system/SystemPricingRatesPage'; /** 主布局守卫:会话恢复中显示启动屏;未认证跳登录页并记住来源路径 */ function RequireSession({ children }: { children: ReactNode }) { @@ -75,10 +76,15 @@ export const router = createBrowserRouter([ }, { path: '/system', - element: , + element: ( + + + + ), children: [ { index: true, element: }, { path: 'platform-catalog', element: }, + { path: 'pricing-rates', element: }, ], }, { diff --git a/apps/web/src/services/pricing.ts b/apps/web/src/services/pricing.ts new file mode 100644 index 0000000..0d9a375 --- /dev/null +++ b/apps/web/src/services/pricing.ts @@ -0,0 +1,27 @@ +import type { + PricingCatalog, + PricingCategory, + PricingPolicy, + PricingPolicyRequest, + PricingPreview, + PricingPreviewRequest, +} from '@atlas/contracts'; +import { api } from './api'; +const headers = () => ({ 'Idempotency-Key': crypto.randomUUID() }); +export const pricingApi = { + categories: (store: string, q: string) => + api.get('/pricing/categories', { params: { store_id: store, q } }), + catalog: (store: string) => + api.get('/pricing/catalog', { params: { store_id: store } }), + preview: (input: PricingPreviewRequest, signal?: AbortSignal) => + api.post('/pricing/quote-previews', input, { headers: headers(), signal }), + createPolicy: (store: string, input: PricingPolicyRequest) => + api.post(`/stores/${store}/pricing-policies`, input, { headers: headers() }), + updatePolicy: (policy: PricingPolicy, input: PricingPolicyRequest) => + api.patch(`/pricing-policies/${policy.id}`, input, { + headers: { ...headers(), 'If-Match': `"${policy.revision}"` }, + }), + defaultPolicy: (id: string) => + api.post(`/pricing-policies/${id}/set-default`, {}, { headers: headers() }), + archivePolicy: (id: string) => api.delete(`/pricing-policies/${id}`, { headers: headers() }), +}; diff --git a/apps/web/src/services/pricingAdmin.ts b/apps/web/src/services/pricingAdmin.ts new file mode 100644 index 0000000..e7208c5 --- /dev/null +++ b/apps/web/src/services/pricingAdmin.ts @@ -0,0 +1,13 @@ +import type { PricingRateCardAdmin, PricingRateReviewRequest } from '@atlas/contracts'; +import { api } from './api'; + +export const pricingAdminApi = { + cards: (platformId: string) => + api.get('/system/pricing/rate-cards', { + params: { platform_id: platformId }, + }), + approve: (cardId: string, input: PricingRateReviewRequest, idempotencyKey: string) => + api.post(`/system/pricing/rate-cards/${cardId}/reviews`, input, { + headers: { 'Idempotency-Key': idempotencyKey }, + }), +}; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index d7e84db..e33baec 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -7,6 +7,10 @@ export default defineConfig({ plugins: [react()], resolve: { alias: { + // 浏览器直接编译共享契约源码,新增导出无需等待 CommonJS 预打包缓存刷新。 + '@atlas/contracts': fileURLToPath( + new URL('../../packages/contracts/src/index.ts', import.meta.url) + ), '@': fileURLToPath(new URL('./src', import.meta.url)), }, }, diff --git a/apps/worker/src/exchange-rate-sync.ts b/apps/worker/src/exchange-rate-sync.ts new file mode 100644 index 0000000..a01f28b --- /dev/null +++ b/apps/worker/src/exchange-rate-sync.ts @@ -0,0 +1,68 @@ +import type { Database } from '@atlas/database'; + +/** CBR 的 Value 按 Nominal 报价;用整数除法换算,避免浮点误差与非预期 XML 内容。 */ +export function parseCbrCny(xml: string) { + const date = xml.match(/]*Date="(\d{2})\.(\d{2})\.(\d{4})"/); + const entry = [...xml.matchAll(/]*>([\s\S]*?)<\/Valute>/g)] + .map((m) => m[1]) + .find((v) => /\s*CNY\s*<\/CharCode>/.test(v)); + const nominal = entry?.match(/\s*(\d{1,6})\s*<\/Nominal>/)?.[1], + value = entry?.match(/\s*(\d{1,9}),?(\d{0,10})\s*<\/Value>/); + if (!date || !nominal || !value || BigInt(nominal) === 0n) + throw new Error('CBR 响应缺少有效人民币报价'); + const effectiveOn = `${date[3]}-${date[2]}-${date[1]}`; + const parsedDate = new Date(`${effectiveOn}T00:00:00Z`); + if ( + !Number.isFinite(parsedDate.getTime()) || + parsedDate.toISOString().slice(0, 10) !== effectiveOn || + parsedDate.getTime() > Date.now() + 2 * 86400000 + ) + throw new Error('CBR 报价日期无效'); + const scaled = + BigInt(value[1]) * 10000000000n + + BigInt((value[2] ?? '').padEnd(10, '0')); + const rate = (scaled + BigInt(nominal) / 2n) / BigInt(nominal); + if (rate <= 0n) throw new Error('CBR 报价必须大于零'); + const digits = rate.toString().padStart(11, '0'); + return { + effectiveOn, + rate: `${digits.slice(0, -10)}.${digits.slice(-10)}`, + }; +} +export async function syncExchangeRate(database: Database) { + await database.transaction(async (tx) => { + const lock = await tx.query<{ locked: boolean }>( + `SELECT pg_try_advisory_xact_lock(74103921) AS locked`, + ); + if (!lock.rows[0].locked) return; + const recent = await tx.query( + `SELECT id FROM atlas.exchange_rates WHERE source='cbr' AND kind='official_reference' AND base_currency_code='CNY' AND quote_currency_code='RUB' AND fetched_at>now()-interval '2 hours' LIMIT 1`, + ); + if (recent.rowCount) return; + const response = await fetch( + 'https://www.cbr.ru/scripts/XML_daily.asp', + { signal: AbortSignal.timeout(15000) }, + ); + if (!response.ok) + throw new Error(`CBR 请求失败:HTTP ${response.status}`); + const bytes = await response.arrayBuffer(); + if (bytes.byteLength > 1000000) throw new Error('CBR 响应大小异常'); + const xml = new TextDecoder('windows-1251').decode(bytes), + rate = parseCbrCny(xml); + await tx.query( + `INSERT INTO atlas.exchange_rates(base_currency_code,quote_currency_code,rate,kind,source,source_reference,effective_on,observed_at) VALUES('CNY','RUB',$1,'official_reference','cbr','https://www.cbr.ru/scripts/XML_daily.asp',$2,now()) ON CONFLICT(base_currency_code,quote_currency_code,kind,source,effective_on) DO UPDATE SET fetched_at=now()`, + [rate.rate, rate.effectiveOn], + ); + }); +} +export async function cleanupPricingPreviews(database: Database) { + const workspaces = await database.query<{ id: string }>( + 'SELECT id FROM atlas.workspaces', + ); + for (const w of workspaces.rows) + await database.workspaceTransaction(w.id, async (tx) => { + await tx.query( + `DELETE FROM atlas.pricing_previews WHERE expires_at{ + await Promise.allSettled([ + syncExchangeRate(database).catch(()=>console.error('pricing_exchange_rate_sync_failed')), + cleanupPricingPreviews(database).catch(()=>console.error('pricing_preview_cleanup_failed')), + ]); +}; +const pricingTimer=setInterval(()=>void pricingMaintenance(),2*60*60_000); +void pricingMaintenance(); void bootstrap().catch((error: unknown) => { console.error( 'worker_startup_failed', @@ -468,6 +478,7 @@ void bootstrap().catch((error: unknown) => { process.exitCode = 1; }); async function shutdown() { + clearInterval(pricingTimer); if (dispatcherTimer) clearInterval(dispatcherTimer); if (mediaCleanupTimer) clearInterval(mediaCleanupTimer); if (platformCatalogTimer) clearInterval(platformCatalogTimer); diff --git a/apps/worker/test/exchange-rate-sync.test.mjs b/apps/worker/test/exchange-rate-sync.test.mjs new file mode 100644 index 0000000..47ccfd6 --- /dev/null +++ b/apps/worker/test/exchange-rate-sync.test.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { parseCbrCny } from '../src/exchange-rate-sync.ts'; +const xml = (nominal, value) => + `CNY${nominal}${value}`; +test('CBR 按面额做精确十进制换算', () => { + assert.deepEqual(parseCbrCny(xml(10, '123,4567')), { + effectiveOn: '2026-09-28', + rate: '12.3456700000', + }); + assert.equal(parseCbrCny(xml(3, '1,0000')).rate, '0.3333333333'); +}); +test('缺失货币、非法日期和零面额不能覆盖有效汇率', () => { + assert.throws(() => parseCbrCny('')); + assert.throws(() => parseCbrCny(xml(0, '12,3'))); + assert.throws(() => parseCbrCny(xml(1, '12,3').replace('28.09', '32.09'))); +}); diff --git a/docs/api/pricing.md b/docs/api/pricing.md index 5440d5c..65eb974 100644 --- a/docs/api/pricing.md +++ b/docs/api/pricing.md @@ -1,188 +1,129 @@ -# 定价工具 API +# 定价试算与门店方案 API -> 所有接口要求 `X-Atlas-Platform-Id`。首期仅支持 Ozon、市场 `RU`、`rfbs` 和已审核启用的 CEL 规则。报价引擎只读取 Atlas 已持久化的汇率、佣金和运价版本;请求期间绝不直接请求 CBR、CEL 或 Ozon。 +> 2026-09-28:维护者批准 SKU 就地试算、详情弹窗、随商品草稿原子确认。本契约替换原规划的独立 `pricing-confirms` 入口。 +> 稳定 DTO 位于 `packages/contracts/src/pricing.ts`;金额、汇率和比例均使用十进制字符串。 -## 1. 统一口径 +## 1. 目录 -- API 使用规范单位:采购成本、国内费用为 `CNY` 字符串;重量为 `kg` 字符串;尺寸为 `cm` 字符串。前端的 g/mm 输入必须先转换。 -- 所有比例以 `0`–`1` 字符串表示,如 `"0.20"` 是 20%。`desired_markup_rate` 是目标净利润 ÷ 采购成本;`list_price_discount_rate` 是买家相对划线价的折扣率。 -- 销售价和划线价均为 `RUB`;`merchandise_value_cny` 是销售价按本次汇率折算得到的 CNY,专门用于选择 CEL 产品类型。 -- 引擎枚举“佣金档 × CEL 产品类型 × 运输方式”,再回验佣金价格区间、CEL 货值区间、重量、尺寸及计费重。没有稳定有效组合时不能返回建议售价。 -- 系统汇率、方案固定汇率、一次性覆盖及临时佣金率都固化到后续 SKU 报价快照,不会改写全局规则或定价方案。 +### GET `/pricing/catalog?store_id={uuid}` -## 2. 只读上下文 +需要已认证工作空间。返回 `{data: PricingCatalog}`: -### 2.1 当前上下文 +- `store`:门店 ID、名称及报价币种(CNY / RUB)。 +- `rate_cards[]`:该门店平台、RU / rFBS 的有效费率卡及运输方式。正式环境必须 active、在有效期内、有审核时间及审核人。`development_only=true` 表示本地开发模式临时可选的 draft 卡。 +- `pending_rate_cards[]`:该平台 RU / rFBS 待审核草稿的名称、版本和状态,仅用于说明阻断原因,不作为方案或报价可选项。 +- `policies[]`:该门店活动方案,默认方案优先,包含完整参数和 revision。 +- `exchange_rate`:CBR 最新 CNY→RUB 官方参考汇率,包含 ID、日期和 stale;缺少数据时为 null。超过 72 小时提示过期参考,不自动代填其他来源。 -`GET /api/pricing/context` +### GET `/pricing/categories?store_id={uuid}&q={text}` -返回当前平台、默认市场、当前工作空间该平台的默认店铺及默认定价方案。没有默认店铺返回 `422 DEFAULT_STORE_REQUIRED`,不猜测目标店铺。 +检索门店平台下活动商品类目,返回 `{data:[{id,name,path}]}`,最多 50 条。`q` 最长 200 字;继续输入名称缩小范围。 -```json -{ "data": { - "platform": { "id": "uuid", "code": "OZON", "name": "Ozon" }, - "market_code": "RU", - "store": { "id": "uuid", "name": "Ozon 主店", "currency_code": "RUB" }, - "default_pricing_policy": { "id": "uuid", "name": "CEL 常规" } -} } -``` +## 2. POST `/pricing/quote-previews` -### 2.2 商品类目、物流目录与系统汇率 - -`GET /api/platform-product-categories?parent_id={uuid|null}&q={keyword}&active_only=true` - -返回 header 平台的商品类目。`parent_id`(懒加载树)与 `q`(搜索)不能同时使用。每项有 `id`、`name`、`path`、`has_children`、`pricing_rule_status`: - -- `available`:当前市场、履约模式下存在唯一有效映射和正式佣金规则; -- `missing`:缺少有效映射或规则; -- `ambiguous`:多个同优先级映射,普通用户不能猜测。 - -不暴露佣金类目 ID 让客户端自行映射。 - -`GET /api/pricing/catalog?fulfillment=rfbs&store_id={uuid}` - -`store_id` 省略时取默认店铺;传入时必须属于当前工作空间与 header 平台。仅返回当前已启用价卡、服务商和运输方式: - -```json -{ "data": { "providers": [{ - "id": "uuid", "code": "CEL", "name": "CEL", - "rate_card": { "id": "uuid", "version": "CEL-OZON-RFBS-V6.17", "effective_from": "2026-09-01T00:00:00Z" }, - "transport_methods": [ - { "id": "uuid", "code": "air", "name": "陆空特快", "speed_label": "5–10 天" }, - { "id": "uuid", "code": "land_air", "name": "陆空标准", "speed_label": "10–15 天" }, - { "id": "uuid", "code": "land", "name": "陆运经济", "speed_label": "15–25 天" } - ] -}] } } -``` - -若规则仍为 `draft` 或未通过真实账单复核,catalog 不返回它;试算请求返回 `422 PRICING_RULES_UNAVAILABLE`。 - -`GET /api/pricing/exchange-rate?base_currency_code=CNY"e_currency_code=RUB` - -读取最新 CBR `official_reference`: - -```json -{ "data": { - "id": "uuid", "base_currency_code": "CNY", "quote_currency_code": "RUB", "rate": "12.33430000", - "source": "cbr", "kind": "official_reference", "effective_on": "2026-09-02", - "fetched_at": "2026-09-02T02:00:00Z", "is_stale": false -} } -``` - -没有系统汇率返回 `404 SYSTEM_EXCHANGE_RATE_NOT_FOUND`;超过 72 小时仍返回但 `is_stale=true`,前端必须明确提示。 - -## 3. 即时试算 - -`POST /api/pricing/quote-previews` - -Headers:`Idempotency-Key` 必填。接口不创建 `pricing_quotes`;`preview_id` 最多可用于后续 SKU 确认 15 分钟,确认时仍会重新计算。 +要求 `Idempotency-Key`。成功返回 201;同键同输入重放原响应,同键不同输入返回 409。 ```json { - "store_id": "uuid", + "store_id": "", + "platform_product_category_id": "", "fulfillment": "rfbs", - "platform_product_category_id": "uuid", - "purchase_cost_cny": "80.00", - "package": { "weight_kg": "0.990", "length_cm": "30", "width_cm": "31", "height_cm": "11" }, + "binding": {"profile_id":"","variant_id":"","client_key":null}, + "purchase_cost_cny": "28.00", + "package": {"shipping_weight_g":"380","length_mm":"220","width_mm":"160","height_mm":"90","status":"confirmed"}, "pricing": { - "pricing_policy_id": "uuid", - "transport_method_ids": ["uuid", "uuid", "uuid"], - "domestic_shipping_and_label_fee_cny": "5.00", - "ad_rate": "0.03", "last_mile_rate": "0.02", "withdrawal_rate": "0.01", "other_rate": "0.02", - "desired_markup_rate": "0.20", "list_price_discount_rate": "0.10", - "fx": { "mode": "system" }, - "temporary_commission_rate": null + "pricing_policy_id": "", + "rate_card_id": "", + "transport_method_ids": [""], + "default_transport_method_id": "", + "domestic_shipping_and_label_fee_cny":"3", + "ad_rate":"0.03","last_mile_rate":"0.02","withdrawal_rate":"0.01","other_rate":"0.02", + "desired_markup_rate":"0.50","list_price_discount_rate":"0.20", + "mode":"target_markup","selling_price":null, + "fx":{"mode":"policy_fixed"},"temporary_commission_rate":null } } ``` -规则: +- 独立试算 `binding=null`;SKU 必须提供 profile,已有 variant_id 与新建 client_key 二选一。验证资源归属,已上架 SKU 不接受新报价。 +- 包装输入是编辑草稿合并后的有效 g/mm 值,必须已确认;尺寸最多三位小数,重量至少 1 g。保存时从实际持久化草稿重新合并并核对,不能仅信任客户端摘要。 +- `mode=target_markup`:目标利润=采购成本×加价率;求解销售价。`fixed_sale`:selling_price 必填,按该售价核算利润。 +- 百分比费用以销售额为基数;除加价率外每项小于 1、最多六位小数。引擎拒绝总扣费率≥1。划线价=销售价÷(1−折扣率)。 +- 枚举运输方式×产品类型×佣金档,按最终币种舍入后回验货值、佣金、重量和体积重边界。默认运输不可用时 recommended_option_id=null,不替换为另一方式。 +- `fx.mode=system` 读取 PostgreSQL 最新 CBR;`policy_fixed` 必须绑定有固定汇率的方案;`one_time_override` 必须明确 rate 和 reason。固定汇率取方案值,不信任客户端覆盖。采用的汇率固定在本次预览中,15 分钟内确认不重新拉取外部汇率。 +- 只有不存在有效正式佣金规则且无方案或方案允许时,才能 temporary_commission_rate。已有正式规则禁止覆盖。 -- `store_id` 可省略,服务端取默认店铺;非空时必须与 header 平台一致。 -- `pricing_policy_id` 非空时方案提供默认值;请求中的费用、目标、运输方式和汇率仅覆盖本次试算,不修改方案。 -- 无方案时所有费用、费率、目标、汇率模式和至少一个运输方式必填。 -- `fx.mode` 为 `system`、`policy_fixed` 或 `one_time_override`;后者必填 `rate`,建议附 `reason`。`policy_fixed` 只对 `fx_strategy=fixed` 方案有效。 -- 正式佣金规则缺失时,仅无方案即时试算或方案 `allow_commission_override=true` 可传 `temporary_commission_rate`。它必须被明确标记为临时覆盖。 -- 未指定运输方式时取方案已启用的比较方式;仍为空则取方案默认运输方式。它们必须属于同一启用价卡。 +响应包含 `preview_id,expires_at,input_hash,recommended_option_id,options,unavailable_options,context`。 +每个 option 包含目标币种销售/划线价、CNY/RUB 利润、计费重、佣金/物流引用、费用明细、warnings 和 can_confirm。 +亏损可查看,但 can_confirm=false;判定使用未舍入利润,显示为 0.00 的微亏也不能确认。 +context 保存规则版本、实际汇率与来源,以及系统汇率记录 ID / 日期。参数/规则快照由服务端持久化。 -返回: +预览按用户、工作空间、门店、SKU、规范化输入与规则摘要绑定,15 分钟有效。Worker 删除过期两天以上的预览。 -```json -{ "data": { - "preview_id": "opaque-short-lived-id", "expires_at": "2026-09-02T08:15:00Z", - "context": { - "store_id": "uuid", "platform_id": "uuid", "market_code": "RU", - "rate_card": { "id": "uuid", "version": "CEL-OZON-RFBS-V6.17" }, - "exchange_rate": { "rate": "12.33430000", "source": "cbr", "effective_on": "2026-09-02", "is_stale": false } - }, - "recommended_option_id": "candidate-1", - "options": [{ - "id": "candidate-1", "is_valid": true, - "transport_method": { "id": "uuid", "code": "land_air", "name": "陆空标准" }, - "logistics_product_type": { "code": "budget", "name": "Budget" }, - "commission": { "rate": "0.120000", "source": "manual_rule", "version": "2026-09", "price_range": { "min": null, "max": "1500.00", "max_inclusive": true } }, - "prices": { "selling_price_rub": "999.00", "list_price_rub": "1110.00", "merchandise_value_cny": "80.99" }, - "weights": { "actual_weight_kg": "0.990", "volume_weight_kg": null, "billable_weight_kg": "0.990", "billing_basis": "actual_only" }, - "fees": { - "purchase_cost_rub": "986.74", "logistics_fee_cny": "49.6600", "logistics_fee_rub": "612.55", - "domestic_fee_cny": "5.0000", "domestic_fee_rub": "61.67", "commission_fee_rub": "119.88", - "ad_fee_rub": "29.97", "last_mile_fee_rub": "19.98", "withdrawal_fee_rub": "9.99", "other_fee_rub": "19.98", - "total_cost_rub": "1860.76", "profit_rub": "-861.76", "gross_margin_rate": "-0.862" - }, - "calculation_trace": { "rounding": [], "formula_steps": [], "rule_versions": [] }, "warnings": [] - }], - "unavailable_options": [{ "transport_method_id": "uuid", "code": "PACKAGE_DIMENSION_EXCEEDED", "message": "最长边超过该方式限制" }] -} } -``` +## 3. 商品保存时确认 -数值只示意响应形状,非真实 CEL 结果。每种运输方式独立成功或列出不可报价原因;一个方式失败不阻断其他方式。推荐仅按完整成本、目标利润和规则有效性计算,不承诺时效。 - -缺少成本、有效包装规格、类目、汇率或佣金规则时返回 `422` 和可修复字段;不返回伪造价格。 - -## 4. 定价方案 - -方案属于 `store + platform`,币种从店铺读取。方案资源的核心形状: +`PUT /products/{id}/editor-draft` 沿用商品/profile revision 的 `If-Match` 和 `Idempotency-Key`,每个 variant 增加: ```json { - "id": "uuid", "store_id": "uuid", "platform_id": "uuid", "name": "CEL 常规", "note": null, - "status": "active", "is_default": true, "fulfillment": "rfbs", - "rate_card": { "id": "uuid", "version": "CEL-OZON-RFBS-V6.17", "provider": { "code": "CEL", "name": "CEL" } }, - "default_transport_method_id": "uuid", "comparison_transport_method_ids": ["uuid", "uuid"], - "fees": { "domestic_shipping_and_label_fee_cny": "5.00", "ad_rate": "0.03", "last_mile_rate": "0.02", "withdrawal_rate": "0.01", "other_rate": "0.02" }, - "desired_markup_rate": "0.20", "list_price_discount_rate": "0.10", "allow_commission_override": false, - "fx": { "strategy": "system", "fixed_cny_to_rub_rate": null }, "revision": 1 + "pricing_draft": "本次 PricingParameters 对象或 null", + "pricing_selection": {"preview_id":"","option_id":"","input_hash":"<64-char SHA256>"} } ``` -`GET /api/stores/{store_id}/pricing-policies?status=active&cursor={opaque}&limit=20` +两个字段均可省略以兼容既有客户端;读取 listing 返回 `pricing_draft,pricing_result,pricing_valid`。 -按 `is_default DESC, updated_at DESC, id DESC` 返回方案。路径店铺必须属于当前工作空间与 header 平台。 +确认在现有商品事务中执行:先保存草稿和新 SKU,再核对实际有效输入、用户绑定、有效期、审核规则摘要、币种及候选资格,最后创建不可变报价/候选快照并切换 listing 售价引用。 +新 SKU 的 client_key 在同一事务映射到真实 ID。独立预览、其他用户或其他 SKU 的预览不能用于确认。 -`GET /api/pricing-policies/{policy_id}` 返回完整方案。 +- 携带失效确认意图:409 `PRICING_PREVIEW_STALE`,details 定位具体 SKU,整笔保存回滚。 +- 不携带确认意图:允许保存未定价草稿;输入/币种变化使报价失效。用户可在冲突弹窗明确选择“仅保存草稿,稍后定价”。 +- 未变化的已确认报价继续使用原快照,无时间过期,不重新读规则或汇率。方案更新/归档不改变其价格。 +- 发布校验要求 pricing_valid=true,无有效报价返回 `PRICING_REQUIRED`;已导入 SKU 沿用既有不可再次首次上架边界。 +- 数据库复合外键保证 quote/profile/product/variant/store/platform 归属,RLS 隔离租户。确认快照拒绝 UPDATE;成本、包装、类目、店铺或币种变化触发失效。 -`POST /api/stores/{store_id}/pricing-policies` +## 4. 门店方案 -Headers:`Idempotency-Key` 必填。请求体采用上方形状的可写字段:创建时 `rate_card_id` 代替 `rate_card`,运输方式以 UUID 数组提交。首个 active 方案强制默认;后续 `is_default=true` 在同一事务取消旧默认。 +| 方法 | 路径 | 约束 | +|---|---|---| +| GET | `/stores/{id}/pricing-policies` | 返回当前门店活动方案数组 | +| POST | `/stores/{id}/pricing-policies` | Idempotency-Key;成功 201 | +| PATCH | `/pricing-policies/{id}` | Idempotency-Key + If-Match(带引号的 revision);冲突 409 | +| POST | `/pricing-policies/{id}/set-default` | Idempotency-Key;门店锁保证唯一默认;成功 200 | +| DELETE | `/pricing-policies/{id}` | Idempotency-Key;归档并清除默认标志,返回 `{id,archived:true}` | -`PATCH /api/pricing-policies/{policy_id}` +写入 `{name,note,parameters,allow_commission_override}`。只保存费用、加价、折扣、固定/系统汇率与运输偏好;采购价、包装、手动售价及临时佣金不属于方案持久字段。 +方案的默认和比较运输必须属于同一可用费率卡;正式环境仅允许已审核卡。历史快照不会被方案修改;尚未确认的预览因方案版本改变要求重算。 -Headers:`If-Match`、`Idempotency-Key` 必填。仅改提交字段;变更履约、价卡或运输方式时服务端重新校验所有关联。变更只影响未来试算/SKU 新确认,绝不重算旧报价。 +## 5. 数据启用与错误 -`POST /api/pricing-policies/{policy_id}/set-default` +迁移中的 CEL 目录为 `draft`,覆盖六种产品类型、三种运输方式,香港空运不纳入。真实账单复核、有效日期/审核人以及平台类目佣金映射仍是正式使用前置条件,接口不会回退到示例费率或固定佣金。 -Headers:`Idempotency-Key` 必填。仅 active 方案可设默认。 +商品编辑联调期间,API 在 `NODE_ENV=development` 时默认允许 draft 费率卡参与方案保存、预览与草稿报价确认;可设 `PRICING_ALLOW_DRAFT_RATES=false` 关闭。测试环境只有显式设 `PRICING_ALLOW_DRAFT_RATES=true` 才开启;生产环境始终关闭。该模式不修改费率卡状态、不生成审核记录。目录的 `rate_cards[].development_only` 和预览的 `context.development_only` 标识试算性质;基于此类预览确认的 SKU 报价可在编辑页查看,但首次上架校验必须返回 `PRICING_RATE_REVIEW_REQUIRED`,直至使用已审核费率重新试算确认。佣金和汇率仍按现有输入规则处理。 -`DELETE /api/pricing-policies/{policy_id}` +### 系统管理员审核 CEL 费率 -Headers:`Idempotency-Key` 必填。将方案置为 `archived`,不物理删除快照。删除默认方案时,若还有 active 方案则返回 `422 DEFAULT_PRICING_POLICY_REPLACEMENT_REQUIRED`,必须先选择替代默认;否则允许无默认方案。 +系统管理「物流费率审核」使用以下接口;全部要求登录且具备实时有效的 `system_admin_grants` 授权,普通门店用户返回 403 `SYSTEM_ADMIN_REQUIRED`: -## 5. SKU 确认衔接 +| 方法 | 路径 | 行为 | +|---|---|---| +| GET | `/system/pricing/rate-cards?platform_id={uuid}` | 查看该平台 RU/rFBS 草稿及已启用费率,含产品类型、规则、审核记录与 `rules_hash` | +| GET | `/system/pricing/rate-cards/{id}` | 重读单个费率版本 | +| POST | `/system/pricing/rate-cards/{id}/reviews` | `Idempotency-Key` UUID 必填;原子保存审核证据并启用费率,成功 201 | -即时试算不落 `pricing_quotes`。商品编辑模块将定义“确认 SKU 报价”:接收 `preview_id`、SKU、最终选中候选及可选一次性覆盖,重新计算后以事务写入 `pricing_quotes + pricing_quote_options`,固化输入、汇率、佣金、CEL 规则版本和费用拆分。 +审核请求包含 `rules_hash`、`source_reference`(资费合同/实际账单批次)、至少 20 字的 `review_note`、`effective_from`(含时区 ISO 时间)和 `samples`。每条样本包含 `rule_id`、唯一的 `invoice_reference`、`merchandise_value_cny`、包装重量 g / 长宽高 mm、`billed_logistics_cny`。每种 CEL 产品类型至少一条,合计不少于六条。服务端使用与正式报价相同的包装适用性和运费函数复算;样本货值须命中对应类型,账单运费与规则计算差额不得超过 0.01 CNY。真实账单来源和口径仍由审核人负责确认,系统不会凭示例数字自行审核。 -客户端不能提交预览中的价格、物流费或规则 ID 作为确认事实。预览过期、规则/汇率更新或包装规格变化时,服务端拒绝旧预览并要求重新试算。临时切换方案或运输方式只进入本次 SKU 快照,绝不修改默认方案。 +仅支持 Ozon/CEL 草稿版本。规则必须仍为 16 条 draft,类型为指定六种,且 `rules_hash` 与管理员查看时一致;否则返回 409/422。通过后在同一数据库事务保存不可修改的审核记录、把规则与费率卡改为 active、设置审核人与生效日期。若生效日期在未来,直到该时间才出现在卖家端可用费率中。数据库拒绝改写已审核的费率卡、产品类型、运输方式和规则;修改资费应新建版本重新审核,不覆盖已确认 SKU 报价。已启用费率不提供再次审核入口。 -## 6. 后台维护边界 +系统管理不会自动配置平台类目佣金映射;缺少正式佣金规则时,卖家试算仍需满足临时佣金覆盖条件。当前 CEL 种子保持 draft,尚无真实账单证据时不得提交启用。 -普通卖家端不提供佣金类目、佣金规则、物流价卡或汇率历史的写接口。它们由受控运营后台、seed/migration 或 Worker 维护;若将来开放运营 API,必须独立鉴权、审计、版本发布和回滚。 +| HTTP / code | 用户动作 | +|---|---| +| 422 VALIDATION_ERROR | 修正输入格式或缺项 | +| 422 PRICING_RULES_UNAVAILABLE | 配置并审核物流费率 | +| 422 PRICING_COMMISSION_UNAVAILABLE | 配置正式映射,或使用允许临时佣金的方案 | +| 422 PRICING_FX_UNAVAILABLE | 等待 CBR 同步,或明确采用临时/方案固定汇率 | +| 409 PRICING_PREVIEW_STALE | 重算、比较并重新应用,或显式仅保存草稿 | +| 409 VERSION_CONFLICT | 重新读取版本后修改 | + +独立上下文、汇率信息整合在 catalog;本期不开放原规划的独立 context、exchange-rate、pricing-confirms 路由和报价历史列表。 diff --git a/docs/api/product-editor-and-publishing.md b/docs/api/product-editor-and-publishing.md index ef2bbc8..038b4a3 100644 --- a/docs/api/product-editor-and-publishing.md +++ b/docs/api/product-editor-and-publishing.md @@ -1,6 +1,6 @@ # 商品编辑与首次上架 API -> M3.0~M3.4 已实现编辑上下文、草稿、SKU、来源素材、类目和动态属性。平台目录刷新方案第 1~4 步已把类目树、类型属性、字典读取及发布预检迁至平台级缓存:编辑器按需刷新,首次发布预检只刷新当前类型和实际使用的字典。AI 填报任务已实现;定价确认和真正调用 Ozon 的首次发布 job 仍未实现。 +> M3.0~M3.4 已实现编辑上下文、草稿、SKU、来源素材、类目和动态属性。平台目录刷新方案第 1~4 步已把类目树、类型属性、字典读取及发布预检迁至平台级缓存:编辑器按需刷新,首次发布预检只刷新当前类型和实际使用的字典。AI 填报任务及随商品保存的定价确认已实现;真正调用 Ozon 的首次发布 job 仍未实现。 > 此处的商品始终是采集箱内的 Atlas 草稿。所有平台范围接口要求 `X-Atlas-Platform-Id`。商品通用资料可以跨平台复用;`product_platform_profile`、SKU 首次导入状态和在线商品不能跨平台/门店复用。 @@ -248,17 +248,13 @@ Body:`{ "variant_ids": ["uuid", "uuid"] }`,最多 100 个且不能重复。 ## 4. SKU 定价确认 -`POST /api/product-platform-profiles/{profile_id}/variants/{variant_id}/pricing-confirms` +定价确认与 `PUT /api/products/{product_id}/editor-draft` 同一事务完成。每个 SKU 可携带 `pricing_draft` 和 `pricing_selection: { preview_id, option_id, input_hash }`;新 SKU 使用 `client_key` 绑定预览,保存时映射为真实 ID。独立试算不能直接确认到 SKU。 -Headers:`Idempotency-Key` 必填。Body: +服务端核对预览归属、15 分钟有效期、当前采购价、有效包装、类目、门店、币种及规则版本,再写入不可变 `pricing_quotes`、全部候选和 listing 价格。任何一个明确选择的报价失效,整个商品保存回滚。客户端可提示用户重新计算,或经用户选择清除本次确认意图后仅保存草稿。 -```json -{ "preview_id": "opaque-id", "selected_option_id": "candidate-1", "one_time_overrides": null } -``` +没有报价时仍可保存商品,但首次发布预检返回 `PRICING_REQUIRED`。已确认报价不会随预览到期而失效;采购、包装、目标门店/类目、履约或门店币种改变后须重新确认。调整默认方案、汇率或费率不会反向改写历史快照。详见[定价 API](./pricing.md)。 -服务端重新解析 SKU 的有效包装规格、验证预览仍未过期且配置/汇率/规则未变化,重新计算后写入 `pricing_quotes`、全部 `pricing_quote_options` 和当前平台 listing 的售价/划线价/币种/报价引用。成功返回 `201` 与不可变报价摘要。 - -包装规格缺失或 `ambiguous`、预览失效、候选不属于预览、目标店铺不一致、物流不适用时返回 `422`,不覆盖既有价格。临时更换方案或运输方式仅写入本次报价,不改门店默认方案。 +开发环境可按定价 API 的草稿费率模式完成商品编辑联调;这种报价即使已保存,也会在首次发布预检返回 `PRICING_RATE_REVIEW_REQUIRED`。费率完成正式审核后,须用已审核卡重新试算并确认 SKU。 ## 5. 素材选择与水印派生 @@ -319,3 +315,10 @@ Headers:`Idempotency-Key` 必填。Body: ## 8. 已替代的旧草案 本文替代 [`../product/api.md`](../product/api.md) 中与商品编辑有关的路径。旧草案保留为历史背景,实施以本文件、[`pricing.md`](./pricing.md) 与 [`collection-and-extension.md`](./collection-and-extension.md) 为准。 + +### 2026-09-28 SKU 定价确认修订 + +草稿的每个 variant 可携带 `pricing_draft` 和 `pricing_selection`,与商品保存原子提交。 +读取 listing 返回 `pricing_draft`、`pricing_result` 和 `pricing_valid`。 +确认失败整笔回滚并定位 SKU;无报价允许保存,但发布预检返回 `PRICING_REQUIRED`。 +输入变化使报价失效;已确认快照保留。具体 DTO 与错误见 pricing.md 实施修订。 diff --git a/docs/product/sku-pricing-workflow-proposal.md b/docs/product/sku-pricing-workflow-proposal.md new file mode 100644 index 0000000..6412f5c --- /dev/null +++ b/docs/product/sku-pricing-workflow-proposal.md @@ -0,0 +1,103 @@ +# SKU 定价试算与确认:产品及实施提案 + +> 2026-09-28 · 维护者已批准本方案,进入实施。第 5 节的契约变更同步到 API 文档与共享契约;原型示例数据不作为生产费率。 + +## 1. 结论 + +建议采用「SKU 就地自动试算 + 详细试算弹窗 + 商品页统一保存」。采购价失焦或按 Enter 后,用该 SKU 当前方案计算并回填销售价、划线价和预估净利润;保留“计算”按钮供主动重算。用户可以关闭自动试算。弹窗用来查看费用、比较物流、调整本次参数,点击“应用到 SKU”回到未保存表单。商品页“保存”才确认报价并持久化。 + +把用户的两种意图分清:输入成本时需要立即看到结果;保存商品时才承诺这次售价。避免每个 SKU 都弹出一次确认框,也避免一次失焦就写入正式报价。 + +## 2. 已阅读的依据与当前差距 + +- 新项目:`requirements/pricing-tool.md`、`requirements/cel-ozon-rfbs-rates-review.md`、`api/pricing.md`、`api/product-editor-and-publishing.md`、`architecture/exchange-rates.md`,以及现有 pricing 表和 SKU 表单。 +- 旧项目:`studio/src/pages/trial/TrialPage.tsx` 的「2 价格试算」、`TrialPricingPanel.tsx`、`studio/src/pricing/pricing.ts`。 +- 旧页面通过 `recalc()` 在参数变化时计算,并通过 `onSave()` 保存参数、售价、划线价与汇率。左右布局、包装数据带入、费用明细和中俄币种对照值得保留。 +- 旧公式把采购成本上的加价率叫“净利率”,使用固定 12%/18% 佣金和 3.5% 其他扣费;low/high/high2 不能表达新的 CEL 货值类型与运输方式,不适合直接迁入 Atlas。 +- Atlas 目前的 SKU 摘要已有采购价输入、只读售价和“净利润 —”;定价工具还是占位页。已有 pricing 基础表,但预览服务、报价确认、汇率 Worker 和方案管理尚需实现。 +- 现有 `pricing-confirms` 契约独立落库,而本提案建议随草稿保存确认;不能只改前端按钮文案,必须先调整保存事务与契约。 + +## 3. SKU 与弹窗交互 + +### 3.1 SKU 卡片 + +摘要行保留:采购价 CNY、销售价(目标店铺币种)、预估净利润 CNY、“计算”、“试算详情”。第二行以较轻的文字显示方案、采用的运输方式、报价状态;包装缺失时显示具体缺项和补充入口。 + +自动算价默认开启,作为用户的编辑偏好。有效值发生变化且字段失焦/按 Enter 后触发,不在每次键入时创建请求。采购价、有效包装规格、目标店铺、类目或 SKU 参数变化均使之前的结果失效;同值失焦不重复请求。包装继承关系仍由 SPU/SKU 原有模型解析。 + +SKU 状态为:未试算 → 计算中 → 已试算·待保存 → 已确认。缺少资料、请求失败或参数已变时显示可修复状态,不把旧利润继续当成当前结果。 + +“按方案计算全部”逐 SKU 返回结果;成功项更新表单,失败项保留输入并标出原因。手动售价不被批量推荐价覆盖,手动项按原售价重新核算利润。 + +### 3.2 手动售价 + +用户可直接修改售价,系统切换到“手动售价”模式,按这个价格计算物流适用性、佣金及利润。此后修改采购价或包装数据,保留售价,仅更新成本和利润。“使用建议售价”是显式退出手动模式的动作。 + +预估净利润只读,不接受手填。标签使用“预估净利润”;它是扣除已配置费用后的预计利润,不是账单结算利润。低于目标或为负时明确显示差额。首期建议亏损报价可保留为草稿,但不得自动成为可发布报价;后续若允许强制确认亏损价,需另行确定权限与确认规则。 + +### 3.3 试算弹窗 + +- 顶部:SKU 名称、当前目标店铺及币种、定价方案。进入时复制该 SKU 的表单输入与参数,取消不修改 SKU。 +- 左侧:采购价、有效包装重量/尺寸、目标加价率、划线折扣率;高级费用折叠展示国内运费/贴单、广告、尾程、提现、其他费、汇率来源和临时覆盖。 +- 页面中的加价率、折扣率、广告、尾程、提现、其他费用率及临时佣金率均按百分数展示和输入,统一保留两位小数;API 仍使用十进制比例字符串,例如页面 `50.00%` 对应请求 `"0.5"`。 +- 右侧:大号销售价、划线价、预估净利润及利润占销售额比例;费用明细与规则来源按需展开。 +- 物流比较:展示方案默认运输方式,同时允许比较其他可用方式。最低价只是标签,不自动替换方案指定的运输方式;指定方式不可用时要求用户选择。 +- 修改参数后延迟约 400 ms 试算,输入未完整时不发送请求;取消旧请求并校验返回版本,防止旧结果覆盖新参数。 +- 底部:取消 / 应用到 SKU。应用只更新当前 SKU 的待保存报价和本次参数,不修改门店默认方案。 +- 用户在弹窗修正包装时,明确写入该 SKU 的包装覆盖值,不修改 SPU 默认规格;原型只演示数值,正式组件需显示继承来源并提供恢复继承操作。 + +### 3.4 独立定价工具与定价方案 + +独立“即时试算”和 SKU 弹窗使用同一输入组件、结果组件与服务端引擎。独立试算不要求已有商品,不提供“应用到 SKU”。 + +“定价方案”负责门店范围的默认扣费、利润目标、折扣、汇率策略和运输偏好;每个门店最多一个默认方案。新 SKU 首次计算时采用它,已有 SKU 继续使用自己的方案/本次覆盖。方案编辑不重算历史已确认报价。将临时参数另存方案应是独立明确动作,采购价和包装尺寸不随之保存。 + +## 4. 保存、变化与失效 + +| 操作或事件 | 表单结果 | 已确认快照 | +|---|---|---| +| 成本失焦,输入完整 | 自动预览并回填,标记待保存 | 保留 | +| 弹窗点击应用 | 覆盖当前 SKU 的待保存报价 | 保留 | +| 手改售价 | 保留手动价,重算费用/利润 | 保留 | +| 包装/类目/店铺变化 | 旧预览失效;重算或提示缺项 | 保留历史,新草稿标记未满足发布报价条件 | +| 方案或系统汇率更新 | 不静默替换已确认价格 | 保持原输入、版本、汇率与结果 | +| 点击商品页保存 | 服务端核对并确认本次报价 | 生成新不可变快照,并切换 listing 引用 | +| 预览过期或规则在保存前变化 | 重算并展示新旧价格/费用差异 | 未经重新接受不替换 | +| 取消弹窗/离开未保存页面 | 放弃相应临时修改 | 不变 | + +保存不能因为某个尚未定价的 SKU 阻止保存整个商品草稿;该 SKU 可保存为“待定价”,发布时阻断。对于携带了报价确认意图的 SKU,只要存在过期/版本冲突,就返回具体 SKU 错误,整个保存事务回滚,不悄悄丢弃确认意图。界面可提供“仅保存草稿,稍后定价”的明确后续操作。 + +已确认报价不因时间经过而自动过期;15 分钟有效期只作用于未确认预览。影响报价的商品输入改变后,当前草稿必须重新确认。发布时检查快照与待发布输入一致,已导入 SKU 沿用既有编辑边界。 + +## 5. 后端及契约建议 + +维护者已批准以下变化: + +1. **预览上下文**:扩展现有 preview 契约以识别 `profile_id + variant_id`,并接收明确的未保存采购价、包装覆盖和本次参数。服务端校验资源归属,并按与草稿保存相同的逻辑合并包装。未保存的新 SKU 需支持当前商品内的客户端草稿标识,保存时原子映射到真实 SKU ID;或首版在首次保存后才开放报价确认。 +2. **两种定价意图**:支持目标加价率求售价,以及固定售价反算利润。输出统一的售价、划线价、利润、费用和规则快照。金额使用十进制定点计算,HTTP 仍传字符串。 +3. **预览标识**:预览绑定用户/工作空间、平台/店铺、SKU、规范化输入摘要、规则/方案/汇率版本及有效期。可保存在有过期清理的 PostgreSQL 预览表;不得只凭客户端提供的金额确认,不以 Redis 作为确认事实来源。 +4. **统一保存**:在 `editor-draft` 中增加每个 SKU 的可选 `pricing_selection`(preview id、候选 id、输入摘要),并为包含报价确认的保存提供幂等键。草稿、SKU、报价/候选快照、listing 售价与报价引用在同一事务提交,沿用 product/profile revision 乐观锁。 +5. **复用确认逻辑**:将当前规划的 `pricing-confirms` 行为放入可被草稿保存事务调用的领域服务。是否保留独立 HTTP 入口需要统一决策;不能采用前端“先保存草稿,再逐个确认报价”来伪装原子保存。 +6. **严格的同归属约束**:补齐报价与 product/profile/variant/listing 的复合外键和工作空间 RLS;现有基础 DDL 中 `pricing_quotes.product_variant_id` 仅是逻辑关联,不能直接当作完整领域约束。 +7. **确认校验**:检查预览输入和待保存草稿一致、版本可用、候选有效、币种及舍入后结果一致;不在确认请求中重新抓取外部汇率。未变化的已确认 SKU 不重复创建报价。 +8. **币种与单位**:售价和划线价以目标店铺支持的币种持久化,CNY 成本为统一输入,RUB 阶梯按本次汇率判定;明确现有 RUB-only 报价列与 CNY 店铺售价的映射。界面可切换展示单位,内部统一 g/mm 或规范化 kg/cm,禁止混算。 + +## 6. 计算引擎 + +复用旧项目的拆分方式与展示经验,重写纯函数计算器。服务端引擎是业务事实来源,前端只负责预览展示。所有入口调用同一引擎。 + +目标加价率基于采购成本:目标利润 = 采购成本 × 加价率。固定成本包含采购、最终跨境物流、国内运费/代贴单及固定附加费。各百分比费用基于最终销售价;划线价 = 销售价 ÷ (1 − 划线折扣率)。 + +引擎枚举“运输方式 × CEL 产品类型 × 佣金档”,计算售价后回验货值范围、重量、尺寸、计抛、佣金边界和最终舍入价格。固定售价模式直接以输入售价确定区间并计算利润。无自洽候选时返回原因,不任意选档或无限迭代。 + +CEL 表尚需真实账单复核;佣金映射与规则也需审核版本。开发可先使用明确标记的测试规则与方案固定汇率推进全链路;正式环境缺少有效规则时禁止伪造价格。CBR 更新、规则发布和过期提示按现有文档落地。 + +## 7. 开发顺序与验收 + +1. **确认交互及契约**:先确认自动失焦回填、手动价保护、随草稿保存这三个选择;再更新需求/API/contracts。影响 Web、API、Worker 汇率任务、database 和 contracts,不复用旧 SQLite 或无鉴权 API。 +2. **引擎和规则**:实现 Decimal 计算、CEL/佣金版本读取、单位转换、候选枚举和固定价反算;测试价格跨档、重量/体积重边界、取整后跨档、总费率 ≥100%、负利润及不可报价。 +3. **首个垂直交付**:一个已保存 SKU、一个门店方案、采购价失焦预览、弹窗改参数、统一保存、刷新重读及发布预检引用报价。此阶段就验证租户隔离、旧响应丢弃、幂等重放、并发版本冲突、确认失败整笔回滚、方案变更不改历史报价。 +4. **扩展编辑效率**:多 SKU 批量试算、缺项定位、包装继承、新 SKU 保存映射、手动售价保护、预览过期后的差异接受。 +5. **完整工具页**:复用同一组件和引擎补独立试算、方案 CRUD、默认方案、汇率同步及真实账单复核。试算记录列表后置。 + +首轮验收:修改采购价后能在 SKU 行看到新售价与利润;弹窗取消不改草稿;应用只改对应 SKU;成本变化不覆盖手动价;保存后重读能解释该价格所用的包装、汇率、佣金和物流规则;失败 SKU 不阻塞其他 SKU 的试算;缺少报价的草稿可保存但不可发布。 diff --git a/docs/project/HANDOFF.md b/docs/project/HANDOFF.md index 7a53d7d..a5f32ea 100644 --- a/docs/project/HANDOFF.md +++ b/docs/project/HANDOFF.md @@ -1,12 +1,12 @@ # Atlas Seller Kit 交接说明 -> 更新:2026-09-27。此文是新 AI 对话、架构讨论与开发工作的首读入口。开始开发前,必须先阅读并遵守 [`development-collaboration-contract.md`](./development-collaboration-contract.md) 和仓库 `AGENTS.md`。下方按日期记录的旧方案仅供追溯;当前状态以本节及最新的 1.47 节为准。 +> 更新:2026-09-28。此文是新 AI 对话、架构讨论与开发工作的首读入口。开始开发前,必须先阅读并遵守 [`development-collaboration-contract.md`](./development-collaboration-contract.md) 和仓库 `AGENTS.md`。下方按日期记录的旧方案仅供追溯;当前状态以本节及最新的 1.47 节为准。 ## 1. 项目定位 这是一个全新的云端产品,不是 `ozon-seller-kit` 的本地版重构。目标用户可拥有多个门店,每个门店只能绑定一个平台;平台自身可支持多个市场、国家、语言和币种。因此语言、币种、类目和发布规则不能写死为 Ozon 或中文/俄文。 -pnpm monorepo 包含 Web、NestJS API、BullMQ Worker、WXT 浏览器扩展、共享契约、数据库包和本地基础设施。认证、工作空间隔离、门店/素材、采集箱、商品编辑草稿、平台级商品参考目录、发布预检和商品 AI 填报已有实现;真正的 Ozon 首次发布、在线商品同步和定价确认仍未形成完整闭环。不要从旧项目复制 SQLite 模型、无鉴权 API、旧 `/trial/:id` 或商品编辑页组件;复用纯规则前须适配多租户和云端边界。 +pnpm monorepo 包含 Web、NestJS API、BullMQ Worker、WXT 浏览器扩展、共享契约、数据库包和本地基础设施。认证、工作空间隔离、门店/素材、采集箱、商品编辑草稿、平台级商品参考目录、发布预检和商品 AI 填报已有实现;SKU 定价试算与确认、在线商品同步已有实现,真正的 Ozon 首次发布仍待接入。不要从旧项目复制 SQLite 模型、无鉴权 API、旧 `/trial/:id` 或商品编辑页组件;复用纯规则前须适配多租户和云端边界。 当前运行时基线:Node 24.20.0、pnpm 11.25.0、PostgreSQL 18.6、Redis 7.4.11、MinIO `RELEASE.2025-09-07T16-13-09Z`;Web 默认端口为 `8100`,API 默认端口为 `8200`,扩展 dev server 默认端口为 `8110`(仅本地开发用),Worker 无 HTTP 端口。完整目录、依赖边界与版本表见 [`architecture/project-structure.md`](../architecture/project-structure.md) 和 [`architecture/technology-and-deployment.md`](../architecture/technology-and-deployment.md)。 @@ -14,12 +14,28 @@ pnpm monorepo 包含 Web、NestJS API、BullMQ Worker、WXT 浏览器扩展、 已完成“采集 → 采集箱 → 商品编辑草稿”的主要数据闭环、平台级类目/属性/字典目录与发布预检。编辑页已接入异步「AI 填报」:基于来源资料与 Atlas 类目树生成类目建议、型号/SKU 货号、证据充分的包装建议及 SPU 中俄文案;单 SKU 文案由用户单独生成并确认应用。AI 结果只回填未保存表单,保存草稿仍是持久化边界。Ozon 动态类目属性可按已选类目加载并手工填写,**当前不由 AI 自动填报**。SKU 图片选择现支持采集候选图、工作空间上传图和网络导入图;在弹窗确认后回填草稿,页面保存后持久化。 -尚未完成可直接发布的闭环:定价确认、真正的 Ozon 发布 job/import 及结果轮询、在线商品同步仍待开发。AI 类目推荐和文案已能运行,但不能等同于跨来源样本验收;尤其应继续用 1688/淘宝/天猫样本核验 Atlas 类目候选质量,以及俄文文案、包装证据和 SKU 继承行为。AI 套图生图仍是占位入口;图片「编辑」菜单中的一键 3:4、添加水印和单张 AI 编辑已接入异步素材任务。 +SKU 定价已打通试算、表单应用与原子保存确认,在线商品同步已有 API/Worker。可直接发布的闭环仍缺真正的 Ozon 发布 job/import 及结果轮询;正式定价还需完成 CEL 费率及类目佣金映射审核。AI 类目推荐和文案已能运行,但不能等同于跨来源样本验收;尤其应继续用 1688/淘宝/天猫样本核验 Atlas 类目候选质量,以及俄文文案、包装证据和 SKU 继承行为。AI 套图生图仍是占位入口;图片「编辑」菜单中的一键 3:4、添加水印和单张 AI 编辑已接入异步素材任务。 -当前优先事项:用真实来源样本回归 AI 类目推荐、文案与图片选择,补做真实图片上传/网络导入联调;完成定价确认;让首次发布 job 复用既有发布预检,再实现 Ozon import 与结果轮询。平台参考目录目前由平台级参考源和同步任务维护,不能把 9 月 10 日的门店级缓存描述当作现行架构。 +当前优先事项:用真实来源样本回归 AI 类目推荐、文案与图片选择,补做真实图片上传/网络导入联调;审核正式物流费率与佣金映射,并用实际结算账单核对 Ozon 平台服务费、收款费和其他扣费;让首次发布 job 复用既有发布预检,再实现 Ozon import 与结果轮询。平台参考目录目前由平台级参考源和同步任务维护,不能把 9 月 10 日的门店级缓存描述当作现行架构。 接手前请先阅读 `git status --short`,确认当前工作区状态;本轮图片选择的 Web、API、contracts、迁移、测试和文档随本报告一起提交。不要用 `git reset --hard` 或批量清理未跟踪文件。共享 contracts 类型需先构建,再执行 Web/API/Worker 检查。开发验证需要时可按 `AGENTS.md` 先确认进程与端口,再启动或重启 API/Worker;下方旧章节中的“不可重启”说法已失效。 +### 2026-09-28:SKU 定价试算与确认 + +- SKU 采购价失焦/回车自动计算、手动算价、批量计算和保留手动售价;明细弹窗比较运输方案、调整参数并应用当前 SKU。 +- 独立试算、门店方案管理和参数另存已实现。Decimal 计算器由服务端统一提供,保存商品时原子确认不可变报价;变更输入使报价失效,首次发布要求有效报价。 +- Worker 增加 CBR 汇率同步与过期预览清理。新增迁移 `043`~`048`;CEL 初始化数据保持 draft,不能直接用于正式算价。 +- 系统管理补充 CEL 费率审核页与管理员 API;迁移 `049` 保存不可修改的审核证据,`050`/`051` 锁定已审核版本及规则归属。六类真实账单样本须按正式物流计费规则复算后才可启用,当前 CEL 种子依然是 draft。 +- `pnpm dev` 会先构建共享依赖;Web 开发读取 contracts 源码,解决新增导出未构建导致启动失败的问题。 +- 本轮影响 Web、API、Worker、contracts、database。验收记录及后续工作见[SKU 定价验证记录](../reviews/sku-pricing-verification.md),HTTP 以[定价 API](../api/pricing.md)为准。 + +### 2026-09-28:Ozon 定价附加费率调查 + +- 已将 Ozon Global 官方公开规则、适用条件、来源链接及 Atlas 字段对应关系写入[Ozon 尾程、收款与其他费率调查](../requirements/ozon-fulfillment-and-payment-fees-research.md)。本次仅新增调查文档和交接记录,没有修改定价实现或方案默认值。 +- Ozon 合作配送的物流平台接入服务费为每票货值的 2%,每票最低 15 ₽、最高 200 ₽;实际配送费另按承运商规则计算。“尾程费率”不能直接理解为全部尾程配送费。 +- 中国卖家的钱包公开费率为 PandaPay 1.10%,LianLian Pay、Payful (GEP)、PingPong 1.20%;“其他费率”没有统一数值。另有适用于 2026-09-01 起订单的国际运输合同服务费,每票货值 0.33%。 +- 当前引擎将 `last_mile_rate`、`withdrawal_rate`、`other_rate` 均按单个 SKU 销售价计入成本。下一步须用 Ozon 结算明细及 CEL 合同/账单确认收费主体、基数、是否重复计入;若要用于正式报价,还需处理每票计费、2% 上下限和合包分摊,不能直接把本调查的比例当作已审核规则。 + ### 1.1 开发进度(2026-09-02) 今日完成三项工作,全部通过 typecheck、生产构建,并经浏览器或启动实测: @@ -608,6 +624,7 @@ User → Workspace → Membership / Role - [商品采集图片与存储策略](../architecture/media-storage-strategy.md) - [实现前待决与待落地事项](../reviews/implementation-readiness-open-items.md) - [浏览器采集插件需求与技术方案](../requirements/collector-browser-extension.md) +- [Ozon 尾程、收款与其他费率调查](../requirements/ozon-fulfillment-and-payment-fees-research.md) - [M2.2 / M2.3 扩展采集交接](./m2.2-m2.3-extension-collection.md) - [M2.4 来源解析与测试页交接](./m2.4-source-parsers-and-inspection.md) - [技术栈、异步任务与部署方案](../architecture/technology-and-deployment.md) diff --git a/docs/requirements/ozon-fulfillment-and-payment-fees-research.md b/docs/requirements/ozon-fulfillment-and-payment-fees-research.md new file mode 100644 index 0000000..654c545 --- /dev/null +++ b/docs/requirements/ozon-fulfillment-and-payment-fees-research.md @@ -0,0 +1,67 @@ +# Ozon 尾程、收款与其他费率调查 + +> 调查日期:2026-09-28。官方页面查询区域选择“中国”。本文件记录 Ozon Global 官方公开规则及其与 Atlas 定价字段的对应关系,不代替店铺合同、CEL 合同和实际结算账单。 + +## 1. 结论 + +| 费用 | Ozon 官方公开规则 | 对 Atlas 当前定价字段的含义 | +|---|---|---| +| “尾程费” | 没有统一的“尾程配送费率”。Ozon 合作配送由物流平台接入服务费和承运商配送费等组成;前者为每票货值的 **2%**,每票最低 **15 ₽**、最高 **200 ₽**;后者由承运商按实重或体积重等规则计算。 | `last_mile_rate = 2.00%` 只能近似表示物流平台接入服务费,不能代表全部尾程运费,也无法体现每票最低、最高收费。 | +| 提现/收款 | 中国卖家可用的钱包费率:**PandaPay 1.10%**;**LianLian Pay、Payful (GEP)、PingPong 1.20%**。 | `withdrawal_rate` 应按店铺实际使用的收款服务商设置;这些是收款服务商费率,不是 Ozon 对所有卖家统一收取的“提现费”。 | +| 其他 | 没有统一的“其他费率”。可明确识别的一项为“签订国际运输组织合同服务费”:**每票货值的 0.33%**,适用于 **2026-09-01 起创建的订单**。买家付款的收单费由处理付款的银行定价;其他付费服务各有规则。 | `other_rate = 0.33%` 仅可作为上述服务适用且未在别处计入时的近似值,不能视为全部其他费用。 | + +来源:[Ozon 合作配送费用](https://global-help.ozon.com/commissions/ozon-fees/partner-delivery-rates)、[Ozon 收款及结算方式](https://global-help.ozon.com/accounting/receiving-payments/settlements)、[Ozon 其他服务费用](https://global-help.ozon.com/commissions/ozon-fees/commissions-for-other-services)。 + +## 2. “尾程费”具体指什么 + +物流语境中的“尾程”通常指包裹进入目的地配送网络后,送至买家、提货点或自提柜的最后一段。它描述配送阶段,并不天然对应一个固定百分比。 + +Ozon 官方的合作配送费用说明将费用拆成至少两类: + +1. **接入 Ozon 物流平台的电子服务费**:每票货值 × 2%,但每票最低 15 ₽、最高 200 ₽。 +2. **商品送达买家的承运商配送费**:按承运商公式计算,以实重或体积重为基础。退货、部分特定渠道还有单独费用。不能将其概括为 2%。 + +因此,当前需求文档使用的“尾程(Ozon 代理费)”名称容易把**平台服务费**与**实际配送费**混在一起。CEL 的 Ozon-rFBS 资费表又另按“每票固定费 + 计费重量单价”给出跨境物流报价。尚无证据证明 CEL 报价包含或不包含 Ozon 上述单独扣费;设置方案前应对照 CEL 合同与 Ozon 结算明细,避免漏计或重复计费。[Ozon 合作配送费用](https://global-help.ozon.com/commissions/ozon-fees/partner-delivery-rates)、[CEL 资费规则复核](./cel-ozon-rfbs-rates-review.md) + +## 3. 收款费率的适用范围 + +Ozon 官方“中国”区域结算说明列出的电子钱包费率如下: + +| 收款服务商 | 官方列示费率 | 可用结算币种 | +|---|---:|---| +| PandaPay | 1.10% | CNY | +| LianLian Pay | 1.20% | CNY | +| Payful (GEP) | 1.20% | CNY、CNH | +| PingPong | 1.20% | CNY | + +这些服务商的公开费率应与实际使用的钱包、合同和到账流水核对。官方页面称其为服务商“佣金”,未在所查说明中明确它与每个 SKU 销售价之间的计算关系。Atlas 当前按 SKU 销售价乘 `withdrawal_rate`,只能用于报价估算;不能把单件估算金额当作最终提现扣费。[Ozon 收款及结算方式](https://global-help.ozon.com/accounting/receiving-payments/settlements) + +## 4. “其他费率”的边界 + +Ozon 官方其他服务费用页说明: + +- “签订国际运输组织合同服务费”按每票货值的 **0.33%**收取,订单创建日期从 **2026-09-01** 起适用,在票件交付或按页面规定的退回状态后扣费。 +- 买家付款的**收单费**由实际处理支付的合作银行定价,因此没有统一的公开百分比。 +- 品牌推广、促销、订阅等服务按是否开通及各自规则另行收费;它们不能被一笔固定“其他费率”准确概括。 + +`other_rate` 在 Atlas 需求中还预留给货损等估算项目。将官方 0.33% 纳入方案前,需确认店铺订单适用该项、结算中实际产生该扣费,且 CEL 费用或其他成本项未覆盖同一笔费用。[Ozon 其他服务费用](https://global-help.ozon.com/commissions/ozon-fees/commissions-for-other-services)、[Atlas 定价需求](./pricing-tool.md) + +## 5. 与当前定价实现的差异及使用建议 + +Atlas 当前将 `last_mile_rate`、`withdrawal_rate`、`other_rate` 相加,分别乘以单个 SKU 的销售价计入成本;字段默认值均为 `0`。需求文档和 API 示例里的比例是方案输入或示例值,并非已审核的 Ozon 官方默认费率。[定价引擎](../../apps/api/src/pricing/pricing-engine.ts)、[定价契约](../../packages/contracts/src/pricing.ts)、[定价需求](./pricing-tool.md)、[定价 API](../api/pricing.md) + +在尚未完成店铺账单核对时,可按以下口径做**临时试算**,并在方案备注中写清来源和假设: + +| 字段 | 临时输入口径 | 需要进一步核实的内容 | +|---|---|---| +| `last_mile_rate` | 若 Ozon 的物流平台接入服务费需单独承担,可输入 **2.00%**。 | CEL 报价是否已覆盖同一费用;每票最低 15 ₽、最高 200 ₽;合包订单分摊方式。 | +| `withdrawal_rate` | PandaPay 输入 **1.10%**;LianLian Pay、Payful (GEP)、PingPong 输入 **1.20%**。 | 实际钱包、合同费率、计费基数及账单扣费。 | +| `other_rate` | 确认适用并单独扣收国际运输合同服务费时,可先输入 **0.33%**;其他项目按事实另估。 | 0.33% 是否产生及是否重复计入;收单费和已开通付费服务。 | + +**精度限制**:Ozon 的 2% 与 0.33% 均以“每票货值”为基数,而 Atlas 当前以“单个 SKU 销售价”为基数;合包、拆单、每票上下限或费用减免时,两者会有差异。正式报价应以 Ozon 店铺后台的**财务 → 店铺经济**、**财务 → 单据**中的扣费明细及 CEL 账单复核,并在确认收费口径后调整数据模型或计算规则。[Ozon 收款及结算方式](https://global-help.ozon.com/accounting/receiving-payments/settlements) + +## 6. 官方来源 + +1. [Ozon Global:合作配送费用](https://global-help.ozon.com/commissions/ozon-fees/partner-delivery-rates),查询区域:中国;含物流平台 2% 及每票上下限、承运商配送费规则。 +2. [Ozon Global:收款及结算方式](https://global-help.ozon.com/accounting/receiving-payments/settlements),查询区域:中国;含四家电子钱包费率及财务明细入口。 +3. [Ozon Global:其他服务费用](https://global-help.ozon.com/commissions/ozon-fees/commissions-for-other-services),查询区域:中国;含 0.33% 服务费生效时间、收单费及可选付费服务。 diff --git a/docs/reviews/implementation-readiness-open-items.md b/docs/reviews/implementation-readiness-open-items.md index 2a44bf3..d0c1b62 100644 --- a/docs/reviews/implementation-readiness-open-items.md +++ b/docs/reviews/implementation-readiness-open-items.md @@ -41,6 +41,8 @@ `product_variant_platform_listings` 同时引用平台发布配置(profile)和通用 SKU(variant),但必须强制:`variant.product_id = profile.product_id`。同一条归属链也适用于属性、报价、发布任务项和在线镜像映射。 +2026-09-28:迁移 `043`~`045` 已补齐报价的同商品、同 profile、同门店/平台归属,以及已选候选属于当前报价的复合外键;确认快照不可更新。跨 SKU 和跨租户测试已通过。 + 实施时优先选用可维护的复合外键/冗余 `product_id` 加一致性约束;如 PostgreSQL 外键表达能力不足,再使用约束触发器。不得只依赖服务层校验。 ### 2. 类目切换后未映射属性的持久化 diff --git a/docs/reviews/sku-pricing-verification.md b/docs/reviews/sku-pricing-verification.md new file mode 100644 index 0000000..b0bf4d6 --- /dev/null +++ b/docs/reviews/sku-pricing-verification.md @@ -0,0 +1,62 @@ +# SKU 定价实现与验证记录 + +日期:2026-09-28。方案:[SKU 定价工作流](../product/sku-pricing-workflow-proposal.md)。接口:[定价 API](../api/pricing.md)。 + +## 实现范围 + +- SKU 采购价失焦/回车自动计算,可关闭自动计算;支持按钮计算和批量计算,手动售价保留。 +- 明细弹窗支持费用、汇率、包装、运输方式比较;取消不改草稿,应用仅回填当前 SKU,保存商品才确认报价。 +- 独立试算与 SKU 共用服务端 Decimal 计算器;门店方案支持创建、编辑、默认、归档和参数另存,另存后列表立即更新。 +- 商品保存事务核对预览归属、期限、输入和规则;确认任一 SKU 失败则全部回滚。新 SKU 使用 client_key 映射真实 ID。 +- 报价与候选不可变,输入改变使有效标记失效;未定价允许保存草稿,首次发布预检要求有效报价。 +- Worker 增加 CBR 官方参考汇率同步、并发锁及过期预览清理。 + +跨领域影响:Web 交互、API 计算/确认、Worker 同步、contracts DTO、database 约束与初始化,以及开发构建依赖。现有 `.env.example` 用户改动未修改。 + +## 自动验证 + +| 验证 | 结果 | +| --- | --- | +| 定价引擎单元测试 | 9 项通过:十进制、币种舍入、体积重、边界、规则适用性、负利润及输入规范化 | +| 定价 HTTP/PostgreSQL 集成测试 | 11 项通过:幂等、租户与 SKU 归属、原子确认、并发、失效、不可变快照、方案管理 | +| 既有商品编辑集成测试 | 12 项通过 | +| 系统物流费率审核 HTTP/PostgreSQL 集成测试 | 1 项通过:管理员鉴权、六类账单复算、重复账单拒绝、并发幂等、已审核版本和规则归属不可改写;与上述测试合计 33 项 | +| CBR 解析测试 | 2 项通过:面额换算与非法响应 | +| contracts、API、API 测试、Web、Worker 类型检查 | 通过 | +| contracts、database、API、Worker 构建及 Web 生产构建 | 通过;Web 仍有较大 bundle 提示 | +| Turbo dev dry-run | API/Worker 先构建 contracts/database,Web/扩展先构建 contracts | +| git diff --check | 通过 | + +CBR 官方公开接口实际请求成功,解析得到生效日期 `2026-09-26`、CNY/RUB `12.5355000000`。同步函数检测到数据库两小时内已有官方记录而跳过重复抓取;本轮未强制改写既有汇率。 + +## 浏览器联调 + +使用隔离工作空间、临时平台、门店及标明测试用途的费率,真实调用本地 API: + +- 采购价变化自动回填售价与利润;手动售价 90 在采购价变化后保持。 +- 明细取消保留原草稿,应用后保存并重读显示“已确认”。 +- 批量计算保留手动售价,缺采购价的 SKU 单独提示,允许其保存未定价草稿。 +- 独立试算样例采购 28 CNY,包装 380 g / 220×160×90 mm,返回售价 77.44 CNY、利润 14 CNY。 +- 参数另存弹窗成功保存,方案管理列表立即显示新方案。 +- Ozon 正式上下文没有审核费率时显示明确空状态。 +- 2026-09-28 补充:目录返回待复核费率卡;试算与方案页明确显示草稿版本及新建方案的阻断原因,并向系统管理员提供审核页入口。 +- 2026-09-28 后续补齐:系统管理新增物流费率审核页及管理员 API。账单样本按正式计费函数复算,审核记录和启用状态同事务保存;普通用户 403、差额不符 422、成功/幂等/重复审核由 PostgreSQL 集成测试覆盖。CEL 正式草稿仍保持待审核,未使用测试账单启用。 +- 管理员真实会话浏览器联调通过:菜单可进入审核页,展示 Ozon CEL 六种产品类型与 16 条规则;审核表单具备六类样本输入与可滚动区域,定价页可直达入口。未提交正式资费审核。 + +联调发现并修复 contracts 开发导出缺失、JSONB 键顺序/金额末尾零导致报价误失效、弹窗应用后自动重算覆盖候选和方案列表未即时更新。最终自动回归以修复后的正式测试文件为准;过程中旧临时夹具出现的失败不计入通过结果。 + +## 本地操作与清理 + +- 迁移器应用原先待执行的 `033`~`042` 及本次 `043`~`051`。新增迁移已应用,不应再编辑其内容。 +- 维护者 Web `8100`、API `8200` 继续运行;未主动停止这两个服务,开发监听器随文件修改自动重载。 +- 临时测试应用已退出;测试平台、门店及工作空间数据已清理,查询临时平台剩余数为 0。 +- 临时测试代码和凭证文件已删除,浏览器恢复 Ozon 平台,任务页面已关闭。 +- 审核测试创建的未来生效 QA 费率已清理(剩余 0);CEL 正式版本 `cel-rfbs-review-2026-09` 仍为 draft,16 条规则仍为 draft,审核时间为空。 + +## 后续业务工作 + +1. 按真实账单审核 CEL 费率和适用边界,补齐正式类目佣金映射。本次 CEL 种子保持 draft,测试费率没有进入正式平台。 +2. 使用真实商品、CNY/RUB 门店回归费用与利润;产品中的净利润是已配置费用下的预估值。 +3. 将 Ozon 首次发布 job、import 提交和结果轮询接到已有发布预检及报价快照。当前预检 ready 不等于已发布。 + +现在可直接运行 `pnpm dev`,无需每次手工先执行完整 `pnpm build`。部署仍须按顺序执行迁移和生产构建。 diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index cd16307..c93db7d 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -6,3 +6,6 @@ export * from './media'; export * from './product-editor'; export * from './product-ai'; export * from './online-products'; + +export * from './pricing'; +export * from './pricing-admin'; diff --git a/packages/contracts/src/pricing-admin.ts b/packages/contracts/src/pricing-admin.ts new file mode 100644 index 0000000..2e7de88 --- /dev/null +++ b/packages/contracts/src/pricing-admin.ts @@ -0,0 +1,81 @@ +import { z } from 'zod'; + +const positiveAmount = z + .string() + .regex(/^\d{1,9}(\.\d{1,4})?$/) + .refine((v) => Number(v) > 0); +export const pricingRateReviewSampleSchema = z + .object({ + rule_id: z.uuid(), + invoice_reference: z.string().trim().min(3).max(300), + merchandise_value_cny: positiveAmount, + shipping_weight_g: positiveAmount, + length_mm: positiveAmount, + width_mm: positiveAmount, + height_mm: positiveAmount, + billed_logistics_cny: positiveAmount, + }) + .strict(); +export const pricingRateReviewRequestSchema = z + .object({ + rules_hash: z.string().regex(/^[a-f0-9]{64}$/), + source_reference: z.string().trim().min(3).max(500), + review_note: z.string().trim().min(20).max(2000), + effective_from: z.iso.datetime({ offset: true }), + samples: z.array(pricingRateReviewSampleSchema).min(6).max(30), + }) + .strict(); +export type PricingRateReviewRequest = z.infer< + typeof pricingRateReviewRequestSchema +>; +export type PricingRateRuleAdmin = { + id: string; + product_type_id: string; + product_type_code: string; + transport_method_id: string; + transport_method_name: string; + fixed_fee_cny: string; + per_billable_kg_fee_cny: string; + min_charge_cny: string | null; + status: string; +}; +export type PricingRateCardAdmin = { + id: string; + platform_id: string; + provider_code: string; + version: string; + source_name: string; + source_uri: string | null; + status: string; + effective_from: string; + reviewed_at: string | null; + reviewer_name: string | null; + rules_hash: string; + types: Array<{ + id: string; + code: string; + min_merchandise_value: string | null; + max_merchandise_value: string | null; + min_value_inclusive: boolean; + max_value_inclusive: boolean; + min_actual_weight_kg: string; + min_actual_weight_inclusive: boolean; + max_actual_weight_kg: string | null; + max_sum_dimensions_cm: string | null; + max_longest_side_cm: string | null; + max_second_longest_side_cm: string | null; + billing_basis: string; + volume_divisor: string | null; + volume_round_scale: number | null; + billing_round_scale: number | null; + max_volume_weight_kg: string | null; + volume_limit_round_scale: number | null; + }>; + rules: PricingRateRuleAdmin[]; + review: { + source_reference: string; + review_note: string; + samples: PricingRateReviewRequest['samples']; + reviewed_at: string; + } | null; +}; diff --git a/packages/contracts/src/pricing.ts b/packages/contracts/src/pricing.ts new file mode 100644 index 0000000..7d8c844 --- /dev/null +++ b/packages/contracts/src/pricing.ts @@ -0,0 +1,273 @@ +import { z } from 'zod'; + +const decimal = z.string().regex(/^\d{1,9}(\.\d{1,8})?$/); +const positive = decimal.refine((value) => Number(value) > 0, '必须大于零'); +const rate = z + .string() + .regex(/^0(\.\d{1,6})?$/) + .refine((value) => Number(value) < 1, '比例必须小于 1'); +export const pricingParametersSchema = z + .object({ + pricing_policy_id: z.uuid().nullable().default(null), + rate_card_id: z.uuid(), + transport_method_ids: z + .array(z.uuid()) + .min(1) + .max(10) + .refine( + (ids) => new Set(ids).size === ids.length, + '运输方式不能重复', + ), + default_transport_method_id: z.uuid().nullable().default(null), + domestic_shipping_and_label_fee_cny: decimal.default('0'), + ad_rate: rate.default('0'), + last_mile_rate: rate.default('0'), + withdrawal_rate: rate.default('0'), + other_rate: rate.default('0'), + desired_markup_rate: z + .string() + .regex(/^\d(\.\d{1,6})?$/) + .refine((v) => Number(v) < 10) + .default('0.5'), + list_price_discount_rate: rate.default('0'), + mode: z.enum(['target_markup', 'fixed_sale']).default('target_markup'), + selling_price: positive.nullable().default(null), + fx: z + .object({ + mode: z.enum(['system', 'policy_fixed', 'one_time_override']), + rate: positive.optional(), + reason: z.string().trim().min(1).max(500).optional(), + }) + .strict() + .default({ mode: 'system' }), + temporary_commission_rate: rate.nullable().default(null), + }) + .strict() + .superRefine((v, ctx) => { + if (v.mode === 'fixed_sale' && !v.selling_price) + ctx.addIssue({ + code: 'custom', + path: ['selling_price'], + message: '请输入手动售价', + }); + if (v.fx.mode === 'one_time_override' && (!v.fx.rate || !v.fx.reason)) + ctx.addIssue({ + code: 'custom', + path: ['fx'], + message: '临时汇率需要数值和原因', + }); + }); +export const pricingPackageSchema = z + .object({ + shipping_weight_g: positive.refine( + (v) => Number(v) >= 1 && (v.split('.')[1]?.length ?? 0) <= 3, + '包装重量至少 1 g,最多三位小数', + ), + length_mm: positive.refine( + (v) => (v.split('.')[1]?.length ?? 0) <= 3, + '最多三位小数', + ), + width_mm: positive.refine( + (v) => (v.split('.')[1]?.length ?? 0) <= 3, + '最多三位小数', + ), + height_mm: positive.refine( + (v) => (v.split('.')[1]?.length ?? 0) <= 3, + '最多三位小数', + ), + status: z.literal('confirmed'), + }) + .strict(); +export const pricingPreviewRequestSchema = z + .object({ + store_id: z.uuid(), + platform_product_category_id: z.uuid(), + fulfillment: z.literal('rfbs'), + binding: z + .object({ + profile_id: z.uuid(), + variant_id: z.uuid().nullable(), + client_key: z.string().min(1).max(100).nullable(), + }) + .strict() + .refine((v) => Boolean(v.variant_id) !== Boolean(v.client_key)) + .nullable() + .default(null), + purchase_cost_cny: z.string().regex(/^\d{1,9}(\.\d{1,2})?$/), + package: pricingPackageSchema, + pricing: pricingParametersSchema, + }) + .strict(); +export const pricingSelectionSchema = z + .object({ + preview_id: z.uuid(), + option_id: z.uuid(), + input_hash: z.string().length(64), + }) + .strict(); +export const pricingPolicyRequestSchema = z + .object({ + name: z.string().trim().min(1).max(100), + note: z.string().max(1000).nullable().default(null), + parameters: pricingParametersSchema, + allow_commission_override: z.boolean().default(false), + }) + .strict() + .superRefine((v, ctx) => { + if (v.parameters.fx.mode === 'one_time_override') + ctx.addIssue({ + code: 'custom', + path: ['parameters', 'fx'], + message: '方案请使用系统或固定汇率', + }); + if (v.parameters.fx.mode === 'policy_fixed' && !v.parameters.fx.rate) + ctx.addIssue({ + code: 'custom', + path: ['parameters', 'fx', 'rate'], + message: '固定汇率必填', + }); + }); +export type PricingParameters = z.infer; +export type PricingPreviewRequest = z.infer; +export type PricingSelection = z.infer; +export type PricingPolicyRequest = z.infer; +export type PricingOption = { + id: string; + transport_method_id: string; + transport_name: string; + product_type_id: string; + product_type_name: string; + logistics_rate_rule_id: string; + commission_rule_id: string | null; + commission_rate: string; + selling_price: string; + list_price: string; + currency_code: string; + selling_price_rub: string; + list_price_rub: string; + merchandise_value_cny: string; + profit_cny: string; + profit_rub: string; + margin_rate: string; + billable_weight_kg: string; + fees: { + logistics_cny: string; + domestic_cny: string; + commission_cny: string; + ad_cny: string; + last_mile_cny: string; + withdrawal_cny: string; + other_cny: string; + }; + warnings: string[]; + can_confirm: boolean; +}; +export type PricingPreview = { + preview_id: string; + expires_at: string; + input_hash: string; + recommended_option_id: string | null; + options: PricingOption[]; + unavailable_options: Array<{ transport_method_id: string; reason: string }>; + context: { + currency_code: string; + fx_rate: string; + fx_origin: string; + rule_version: string; + development_only: boolean; + exchange_rate: { + id: string; + rate: string; + effective_on: string; + stale: boolean; + } | null; + warnings: string[]; + }; +}; +export type PricingPolicy = PricingPolicyRequest & { + id: string; + store_id: string; + revision: number; + is_default: boolean; +}; +export type PricingCatalog = { + store: { id: string; name: string; currency_code: string }; + pending_rate_cards: Array<{ + id: string; + name: string; + version: string; + status: 'draft'; + }>; + rate_cards: Array<{ + id: string; + name: string; + version: string; + development_only: boolean; + transports: Array<{ id: string; name: string }>; + }>; + policies: PricingPolicy[]; + exchange_rate: { + id: string; + rate: string; + effective_on: string; + stale: boolean; + } | null; +}; + +export type PricingCategory = { id: string; name: string; path: string }; + +/** 数值字符串仅去掉无意义零位,前后端共享输入比较口径;不进行浮点计算。 */ +export function normalizePricingInput( + input: PricingPreviewRequest, +): PricingPreviewRequest { + const number = (value: string) => + typeof value !== 'string' + ? value + : value + .replace(/^0+(?=\d)/, '') + .replace(/(\.\d*?)0+$/, '$1') + .replace(/\.$/, ''); + const pricing = { ...input.pricing, fx: { ...input.pricing.fx } }; + for (const key of [ + 'domestic_shipping_and_label_fee_cny', + 'ad_rate', + 'last_mile_rate', + 'withdrawal_rate', + 'other_rate', + 'desired_markup_rate', + 'list_price_discount_rate', + 'selling_price', + 'temporary_commission_rate', + ] as const) { + const value = pricing[key]; + if (value !== null) pricing[key] = number(value); + } + if (pricing.fx.rate) pricing.fx.rate = number(pricing.fx.rate); + return { + ...input, + purchase_cost_cny: number(input.purchase_cost_cny), + package: { + ...input.package, + shipping_weight_g: number(input.package.shipping_weight_g), + length_mm: number(input.package.length_mm), + width_mm: number(input.package.width_mm), + height_mm: number(input.package.height_mm), + }, + pricing, + }; +} + +/** 输入比较不受对象键顺序、PostgreSQL JSONB 重排或数值格式影响。 */ +export function pricingInputKey(input: PricingPreviewRequest): string { + return JSON.stringify( + normalizePricingInput(input), + (_key, value: unknown) => + value && typeof value === 'object' && !Array.isArray(value) + ? Object.fromEntries( + Object.entries(value).sort(([a], [b]) => + a.localeCompare(b), + ), + ) + : value, + ); +} diff --git a/packages/contracts/src/product-editor.ts b/packages/contracts/src/product-editor.ts index e75dab5..156851c 100644 --- a/packages/contracts/src/product-editor.ts +++ b/packages/contracts/src/product-editor.ts @@ -1,3 +1,9 @@ +import { + pricingParametersSchema, + pricingSelectionSchema, + type PricingParameters, + type PricingOption, +} from './pricing'; import { z } from 'zod'; import { productCopyTranslationSchema, @@ -246,6 +252,9 @@ export type ProductEditorSnapshot = { id: string; offer_id: string | null; status: string; + pricing_draft?: PricingParameters | null; + pricing_result?: PricingOption | null; + pricing_valid?: boolean; selling_price: string | null; list_price: string | null; currency_code: string | null; @@ -477,7 +486,7 @@ export type ProductPublishPreflightRequest = z.infer< typeof productPublishPreflightRequestSchema >; -/** M3.1 intentionally covers editable product/profile/SKU fundamentals. */ +/** 商品、发布配置与 SKU(含报价确认意图)原子保存。 */ export const saveProductEditorDraftRequestSchema = z .object({ ai_source_material: productAiSourceMaterialSchema, @@ -533,6 +542,8 @@ export const saveProductEditorDraftRequestSchema = z .max(100) .nullable(), copy_from_variant_id: uuid.nullable().optional(), + pricing_draft: pricingParametersSchema.nullable().optional(), + pricing_selection: pricingSelectionSchema.nullable().optional(), display_name: nullableText(1_000), source_sku: nullableText(500), specification_values: z @@ -571,7 +582,11 @@ export const saveProductEditorDraftRequestSchema = z }), ) .min(1) - .max(100), + .max(100) + .refine( + (variants) => new Set(variants.map((v) => v.id ? `id:${v.id}` : `client:${v.client_key}`)).size === variants.length, + 'SKU 标识不能重复', + ), }) .strict(); diff --git a/packages/database/migrations/043_sku_pricing.sql b/packages/database/migrations/043_sku_pricing.sql new file mode 100644 index 0000000..cbcc5c9 --- /dev/null +++ b/packages/database/migrations/043_sku_pricing.sql @@ -0,0 +1,51 @@ +SET search_path TO atlas, public; + +ALTER TABLE logistics_product_types ADD COLUMN min_actual_weight_inclusive BOOLEAN NOT NULL DEFAULT true; +ALTER TABLE pricing_quotes ADD COLUMN product_id UUID, ADD COLUMN profile_id UUID, ADD COLUMN input_hash TEXT; +ALTER TABLE pricing_quotes ADD CONSTRAINT pricing_quote_variant_product_fk + FOREIGN KEY (workspace_id, product_variant_id, product_id) REFERENCES product_variants(workspace_id,id,product_id); +ALTER TABLE pricing_quotes ADD CONSTRAINT pricing_quote_profile_product_fk + FOREIGN KEY (workspace_id,profile_id,product_id,platform_id) REFERENCES product_platform_profiles(workspace_id,id,product_id,platform_id); +ALTER TABLE pricing_quotes ADD CONSTRAINT pricing_quote_binding_complete CHECK ( + (product_variant_id IS NULL AND product_id IS NULL AND profile_id IS NULL) OR + (product_variant_id IS NOT NULL AND product_id IS NOT NULL AND profile_id IS NOT NULL)); +ALTER TABLE pricing_quotes ADD CONSTRAINT pricing_quote_listing_identity UNIQUE(workspace_id,id,profile_id,product_variant_id,store_id,platform_id); +ALTER TABLE pricing_quote_options ADD CONSTRAINT pricing_option_identity UNIQUE(workspace_id,quote_id,id); +ALTER TABLE pricing_quotes DROP CONSTRAINT fk_pricing_quotes_selected_option; +ALTER TABLE pricing_quotes ADD CONSTRAINT pricing_quote_selected_option_fk + FOREIGN KEY(workspace_id,id,selected_option_id) REFERENCES pricing_quote_options(workspace_id,quote_id,id) DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE product_variant_platform_listings + ADD COLUMN pricing_quote_id UUID, + ADD COLUMN pricing_draft JSONB, + ADD COLUMN pricing_valid BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE product_variant_platform_listings ADD CONSTRAINT listing_pricing_quote_fk + FOREIGN KEY(workspace_id,pricing_quote_id,profile_id,product_variant_id,store_id,platform_id) + REFERENCES pricing_quotes(workspace_id,id,profile_id,product_variant_id,store_id,platform_id) DEFERRABLE INITIALLY DEFERRED; + +CREATE TABLE pricing_previews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL REFERENCES workspaces(id), + user_id UUID NOT NULL REFERENCES users(id), store_id UUID NOT NULL, platform_id UUID NOT NULL, + input JSONB NOT NULL, input_hash TEXT NOT NULL, rules_hash TEXT NOT NULL, result JSONB NOT NULL, + expires_at TIMESTAMPTZ NOT NULL DEFAULT now()+interval '15 minutes', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY(workspace_id,store_id,platform_id) REFERENCES stores(workspace_id,id,platform_id), + UNIQUE(workspace_id,id) +); +CREATE INDEX pricing_previews_expiry ON pricing_previews(expires_at); +ALTER TABLE pricing_previews ENABLE ROW LEVEL SECURITY; +ALTER TABLE pricing_previews FORCE ROW LEVEL SECURITY; +CREATE POLICY workspace_isolation ON pricing_previews USING(workspace_id=app_current_workspace_id()) WITH CHECK(workspace_id=app_current_workspace_id()); + +-- 通用商品输入影响所有平台报价,其他平台必须同步失效;当前保存可在复核后恢复有效状态。 +CREATE FUNCTION invalidate_variant_pricing() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF TG_TABLE_NAME='product_variants' THEN + UPDATE product_variant_platform_listings SET pricing_valid=false WHERE product_variant_id=NEW.id; + ELSE + UPDATE product_variant_platform_listings SET pricing_valid=false WHERE product_id=NEW.id; + END IF; + RETURN NEW; +END $$; +CREATE TRIGGER variant_pricing_changed AFTER UPDATE OF purchase_cost_cny,package_length_mm,package_width_mm,package_height_mm,shipping_weight_g,package_spec_status ON product_variants +FOR EACH ROW WHEN (ROW(OLD.purchase_cost_cny,OLD.package_length_mm,OLD.package_width_mm,OLD.package_height_mm,OLD.shipping_weight_g,OLD.package_spec_status) IS DISTINCT FROM ROW(NEW.purchase_cost_cny,NEW.package_length_mm,NEW.package_width_mm,NEW.package_height_mm,NEW.shipping_weight_g,NEW.package_spec_status)) EXECUTE FUNCTION invalidate_variant_pricing(); +CREATE TRIGGER product_pricing_changed AFTER UPDATE OF default_package_length_mm,default_package_width_mm,default_package_height_mm,default_shipping_weight_g,package_spec_status ON products +FOR EACH ROW WHEN (ROW(OLD.default_package_length_mm,OLD.default_package_width_mm,OLD.default_package_height_mm,OLD.default_shipping_weight_g,OLD.package_spec_status) IS DISTINCT FROM ROW(NEW.default_package_length_mm,NEW.default_package_width_mm,NEW.default_package_height_mm,NEW.default_shipping_weight_g,NEW.package_spec_status)) EXECUTE FUNCTION invalidate_variant_pricing(); diff --git a/packages/database/migrations/044_pricing_integrity.sql b/packages/database/migrations/044_pricing_integrity.sql new file mode 100644 index 0000000..0c3bc00 --- /dev/null +++ b/packages/database/migrations/044_pricing_integrity.sql @@ -0,0 +1,26 @@ +SET search_path TO atlas, public; + +-- 固定函数查找路径,业务连接不依赖迁移会话的 search_path。 +ALTER FUNCTION invalidate_variant_pricing() SET search_path = atlas, public; + +-- 防止数据库绕过接口拼接其他费率卡的运输规则。 +CREATE FUNCTION check_pricing_transport_card() RETURNS trigger LANGUAGE plpgsql SET search_path = atlas, public AS $$ +BEGIN + IF TG_TABLE_NAME='logistics_rate_rules' THEN + IF NOT EXISTS (SELECT 1 FROM logistics_product_types p JOIN logistics_transport_methods t ON t.rate_card_id=p.rate_card_id WHERE p.id=NEW.product_type_id AND t.id=NEW.transport_method_id) THEN + RAISE EXCEPTION '物流类型和运输方式必须属于同一费率卡' USING ERRCODE='23514'; + END IF; + ELSIF TG_TABLE_NAME='pricing_policies' THEN + IF NEW.default_transport_method_id IS NOT NULL AND NOT EXISTS(SELECT 1 FROM logistics_transport_methods WHERE id=NEW.default_transport_method_id AND rate_card_id=NEW.rate_card_id) THEN + RAISE EXCEPTION '方案默认运输方式必须属于费率卡' USING ERRCODE='23514'; + END IF; + ELSE + IF NOT EXISTS(SELECT 1 FROM pricing_policies p JOIN logistics_transport_methods t ON t.rate_card_id=p.rate_card_id WHERE p.id=NEW.pricing_policy_id AND p.workspace_id=NEW.workspace_id AND t.id=NEW.transport_method_id) THEN + RAISE EXCEPTION '方案比较方式必须属于费率卡' USING ERRCODE='23514'; + END IF; + END IF; + RETURN NEW; +END $$; +CREATE CONSTRAINT TRIGGER logistics_transport_card AFTER INSERT OR UPDATE ON logistics_rate_rules DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_pricing_transport_card(); +CREATE CONSTRAINT TRIGGER policy_transport_card AFTER INSERT OR UPDATE ON pricing_policies DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_pricing_transport_card(); +CREATE CONSTRAINT TRIGGER policy_comparison_card AFTER INSERT OR UPDATE ON pricing_policy_transport_methods DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_pricing_transport_card(); diff --git a/packages/database/migrations/045_pricing_snapshot_guards.sql b/packages/database/migrations/045_pricing_snapshot_guards.sql new file mode 100644 index 0000000..ebc1ce4 --- /dev/null +++ b/packages/database/migrations/045_pricing_snapshot_guards.sql @@ -0,0 +1,18 @@ +SET search_path TO atlas, public; + +ALTER TABLE product_variant_platform_listings ADD CONSTRAINT valid_pricing_requires_quote CHECK(NOT pricing_valid OR pricing_quote_id IS NOT NULL); + +-- 确认后只新增报价切换引用,不允许修改历史金额或规则快照。 +CREATE FUNCTION prevent_confirmed_pricing_mutation() RETURNS trigger LANGUAGE plpgsql SET search_path = atlas, public AS $$ +BEGIN + IF TG_TABLE_NAME='pricing_quotes' THEN + IF OLD.status='confirmed' THEN RAISE EXCEPTION '已确认报价不可改写' USING ERRCODE='23514'; END IF; + ELSE + IF EXISTS(SELECT 1 FROM pricing_quotes WHERE id=OLD.quote_id AND workspace_id=OLD.workspace_id AND status='confirmed') THEN + RAISE EXCEPTION '已确认候选快照不可改写' USING ERRCODE='23514'; + END IF; + END IF; + RETURN NEW; +END $$; +CREATE TRIGGER confirmed_quote_immutable BEFORE UPDATE ON pricing_quotes FOR EACH ROW EXECUTE FUNCTION prevent_confirmed_pricing_mutation(); +CREATE TRIGGER confirmed_option_immutable BEFORE UPDATE ON pricing_quote_options FOR EACH ROW EXECUTE FUNCTION prevent_confirmed_pricing_mutation(); diff --git a/packages/database/migrations/046_profile_pricing_invalidation.sql b/packages/database/migrations/046_profile_pricing_invalidation.sql new file mode 100644 index 0000000..e475a5b --- /dev/null +++ b/packages/database/migrations/046_profile_pricing_invalidation.sql @@ -0,0 +1,9 @@ +SET search_path TO atlas, public; + +CREATE FUNCTION invalidate_profile_pricing() RETURNS trigger LANGUAGE plpgsql SET search_path = atlas, public AS $$ +BEGIN + UPDATE product_variant_platform_listings SET pricing_valid=false WHERE profile_id=NEW.id; + RETURN NEW; +END $$; +CREATE TRIGGER profile_pricing_changed AFTER UPDATE OF target_store_id,platform_product_category_id,fulfillment ON product_platform_profiles +FOR EACH ROW WHEN (ROW(OLD.target_store_id,OLD.platform_product_category_id,OLD.fulfillment) IS DISTINCT FROM ROW(NEW.target_store_id,NEW.platform_product_category_id,NEW.fulfillment)) EXECUTE FUNCTION invalidate_profile_pricing(); diff --git a/packages/database/migrations/047_cel_draft_rate_card.sql b/packages/database/migrations/047_cel_draft_rate_card.sql new file mode 100644 index 0000000..37797c0 --- /dev/null +++ b/packages/database/migrations/047_cel_draft_rate_card.sql @@ -0,0 +1,37 @@ +SET search_path TO atlas, public; + +-- 来源为仓库 CEL 待复核表;仅建立草稿目录,禁止自动启用或填入虚构审核人。 +INSERT INTO logistics_providers(code,name,status) VALUES('CEL','CEL','active') ON CONFLICT(code) DO NOTHING; +INSERT INTO logistics_rate_cards(provider_id,platform_id,fulfillment,market_code,version,source_name,status,effective_from) +SELECT p.id,f.id,'rfbs','RU','cel-rfbs-review-2026-09','CEL Ozon rFBS · 待真实账单复核','draft',now() +FROM logistics_providers p CROSS JOIN platforms f WHERE p.code='CEL' AND upper(f.code::text)='OZON' +ON CONFLICT(provider_id,platform_id,fulfillment,market_code,version) DO NOTHING; + +INSERT INTO logistics_transport_methods(rate_card_id,code,name,status) +SELECT c.id,v.code,v.name,'active' FROM logistics_rate_cards c CROSS JOIN (VALUES('express','特快'),('standard','标准'),('economy','经济')) v(code,name) +WHERE c.version='cel-rfbs-review-2026-09' ON CONFLICT(rate_card_id,code) DO NOTHING; + +INSERT INTO logistics_product_types(rate_card_id,code,name,merchandise_currency_code,min_merchandise_value,max_merchandise_value,min_value_inclusive,max_value_inclusive,min_actual_weight_kg,min_actual_weight_inclusive,max_actual_weight_kg,max_sum_dimensions_cm,max_longest_side_cm,max_second_longest_side_cm,billing_basis,volume_divisor,volume_round_scale,billing_round_scale,max_volume_weight_kg,volume_limit_round_scale) +SELECT c.id,v.code,v.code,'CNY',v.min_value,v.max_value,false,true,v.min_weight,v.min_inclusive,v.max_weight,v.max_sum,v.max_side,v.max_second,v.basis::billing_weight_basis,v.divisor,v.round_scale,3,v.max_volume,v.limit_scale +FROM logistics_rate_cards c CROSS JOIN (VALUES +('ExtraSmall',NULL::numeric,135,0.001,true,0.5,90,60,NULL::numeric,'actual_only',NULL::numeric,NULL::integer,NULL::numeric,NULL::integer), +('Budget',NULL,135,0.5,false,30,150,60,NULL,'actual_only',NULL,NULL,NULL,NULL), +('Small',135,635,0.001,true,2,150,60,NULL,'actual_only',NULL,NULL,NULL,NULL), +('Big',135,635,2,false,30,310,150,NULL,'max_actual_volume',12000,3,31,0), +('PremiumSmall',635,22525,0.001,true,5,250,150,NULL,'actual_only',NULL,NULL,NULL,NULL), +('PremiumBig',635,22525,5,false,30,310,150,80,'max_actual_volume',12000,3,80,0) +) v(code,min_value,max_value,min_weight,min_inclusive,max_weight,max_sum,max_side,max_second,basis,divisor,round_scale,max_volume,limit_scale) +WHERE c.version='cel-rfbs-review-2026-09' ON CONFLICT(rate_card_id,code) DO NOTHING; + +INSERT INTO logistics_rate_rules(product_type_id,transport_method_id,fixed_fee_cny,per_billable_kg_fee_cny,status) +SELECT t.id,m.id,v.fixed_fee,v.per_kg,'draft' FROM logistics_product_types t +JOIN logistics_rate_cards c ON c.id=t.rate_card_id JOIN logistics_transport_methods m ON m.rate_card_id=c.id +JOIN (VALUES +('ExtraSmall','express',3.12,46.8),('ExtraSmall','standard',3.12,36.4),('ExtraSmall','economy',3.12,26), +('Budget','express',23.92,34.32),('Budget','standard',23.92,26),('Budget','economy',23.92,17.68), +('Small','express',16.64,46.8),('Small','standard',16.64,36.4),('Small','economy',16.64,26), +('Big','standard',37.44,26),('Big','economy',37.44,17.68), +('PremiumSmall','express',22.88,46.8),('PremiumSmall','standard',22.88,36.4),('PremiumSmall','economy',22.88,26), +('PremiumBig','standard',64.48,29.12),('PremiumBig','economy',64.48,23.92) +) v(type_code,transport_code,fixed_fee,per_kg) ON t.code=v.type_code AND m.code=v.transport_code +WHERE c.version='cel-rfbs-review-2026-09' ON CONFLICT(product_type_id,transport_method_id) DO NOTHING; diff --git a/packages/database/migrations/048_store_pricing_invalidation.sql b/packages/database/migrations/048_store_pricing_invalidation.sql new file mode 100644 index 0000000..f670a61 --- /dev/null +++ b/packages/database/migrations/048_store_pricing_invalidation.sql @@ -0,0 +1,10 @@ +SET search_path TO atlas, public; + +-- 币种变化影响售价含义;历史快照保留,发布必须重新确认。 +CREATE FUNCTION invalidate_store_currency_pricing() RETURNS trigger LANGUAGE plpgsql SET search_path = atlas, public AS $$ +BEGIN + UPDATE product_variant_platform_listings SET pricing_valid=false WHERE store_id=NEW.id; + RETURN NEW; +END $$; +CREATE TRIGGER store_currency_pricing_changed AFTER UPDATE OF currency_code ON stores +FOR EACH ROW WHEN (OLD.currency_code IS DISTINCT FROM NEW.currency_code) EXECUTE FUNCTION invalidate_store_currency_pricing(); diff --git a/packages/database/migrations/049_logistics_rate_reviews.sql b/packages/database/migrations/049_logistics_rate_reviews.sql new file mode 100644 index 0000000..911d106 --- /dev/null +++ b/packages/database/migrations/049_logistics_rate_reviews.sql @@ -0,0 +1,20 @@ +SET search_path TO atlas, public; + +-- 平台费率审核是系统级事实;证据与复算样本随启用事务一起保存。 +CREATE TABLE logistics_rate_card_reviews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + rate_card_id UUID NOT NULL UNIQUE REFERENCES logistics_rate_cards(id) ON DELETE RESTRICT, + reviewer_user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + idempotency_key UUID NOT NULL, + request_hash CHAR(64) NOT NULL, + rules_hash CHAR(64) NOT NULL, + source_reference TEXT NOT NULL CHECK (length(trim(source_reference)) BETWEEN 3 AND 500), + review_note TEXT NOT NULL CHECK (length(trim(review_note)) BETWEEN 20 AND 2000), + samples JSONB NOT NULL CHECK (jsonb_typeof(samples) = 'array' AND jsonb_array_length(samples) >= 6), + effective_from TIMESTAMPTZ NOT NULL, + reviewed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (reviewer_user_id, idempotency_key) +); + +-- 审核记录不可改写;后续资费变化须建立新费率版本并重新审核。 +REVOKE UPDATE, DELETE ON logistics_rate_card_reviews FROM atlas_app; diff --git a/packages/database/migrations/050_reviewed_logistics_immutable.sql b/packages/database/migrations/050_reviewed_logistics_immutable.sql new file mode 100644 index 0000000..3825982 --- /dev/null +++ b/packages/database/migrations/050_reviewed_logistics_immutable.sql @@ -0,0 +1,37 @@ +SET search_path TO atlas, public; + +-- 审核通过的规则只能由新版本替代;应用角色不能直接修改已经用于报价的运价。 +CREATE FUNCTION guard_reviewed_logistics_mutation() RETURNS trigger LANGUAGE plpgsql SET search_path = atlas, public AS $$ +DECLARE + card_id UUID; +BEGIN + IF TG_TABLE_NAME='logistics_rate_cards' THEN + card_id := OLD.id; + ELSIF TG_TABLE_NAME IN ('logistics_product_types','logistics_transport_methods') THEN + IF TG_OP='DELETE' THEN card_id := OLD.rate_card_id; + ELSE card_id := NEW.rate_card_id; END IF; + ELSE + IF TG_OP='DELETE' THEN + SELECT rate_card_id INTO card_id FROM logistics_product_types WHERE id=OLD.product_type_id; + ELSE + SELECT rate_card_id INTO card_id FROM logistics_product_types WHERE id=NEW.product_type_id; + END IF; + END IF; + IF card_id IS NOT NULL THEN + PERFORM 1 FROM logistics_rate_cards WHERE id=card_id FOR SHARE; + IF EXISTS (SELECT 1 FROM logistics_rate_card_reviews WHERE rate_card_id=card_id) THEN + RAISE EXCEPTION '已审核的物流费率不可改写,请建立新版本' USING ERRCODE='23514'; + END IF; + END IF; + IF TG_OP='DELETE' THEN RETURN OLD; END IF; + RETURN NEW; +END $$; + +CREATE TRIGGER reviewed_card_immutable BEFORE UPDATE OR DELETE ON logistics_rate_cards + FOR EACH ROW EXECUTE FUNCTION guard_reviewed_logistics_mutation(); +CREATE TRIGGER reviewed_type_immutable BEFORE INSERT OR UPDATE OR DELETE ON logistics_product_types + FOR EACH ROW EXECUTE FUNCTION guard_reviewed_logistics_mutation(); +CREATE TRIGGER reviewed_method_immutable BEFORE INSERT OR UPDATE OR DELETE ON logistics_transport_methods + FOR EACH ROW EXECUTE FUNCTION guard_reviewed_logistics_mutation(); +CREATE TRIGGER reviewed_rule_immutable BEFORE INSERT OR UPDATE OR DELETE ON logistics_rate_rules + FOR EACH ROW EXECUTE FUNCTION guard_reviewed_logistics_mutation(); diff --git a/packages/database/migrations/051_reviewed_logistics_scope_guard.sql b/packages/database/migrations/051_reviewed_logistics_scope_guard.sql new file mode 100644 index 0000000..d4d4e43 --- /dev/null +++ b/packages/database/migrations/051_reviewed_logistics_scope_guard.sql @@ -0,0 +1,31 @@ +SET search_path TO atlas, public; + +-- 更新规则归属时同时检查原费率卡和目标费率卡,避免把已审核规则移出后改写。 +CREATE OR REPLACE FUNCTION guard_reviewed_logistics_mutation() RETURNS trigger LANGUAGE plpgsql SET search_path = atlas, public AS $$ +DECLARE + old_card_id UUID; + new_card_id UUID; + current_card_id UUID; +BEGIN + IF TG_TABLE_NAME='logistics_rate_cards' THEN + old_card_id := OLD.id; + ELSIF TG_TABLE_NAME IN ('logistics_product_types','logistics_transport_methods') THEN + IF TG_OP<>'INSERT' THEN old_card_id := OLD.rate_card_id; END IF; + IF TG_OP<>'DELETE' THEN new_card_id := NEW.rate_card_id; END IF; + ELSE + IF TG_OP<>'INSERT' THEN + SELECT rate_card_id INTO old_card_id FROM logistics_product_types WHERE id=OLD.product_type_id; + END IF; + IF TG_OP<>'DELETE' THEN + SELECT rate_card_id INTO new_card_id FROM logistics_product_types WHERE id=NEW.product_type_id; + END IF; + END IF; + FOR current_card_id IN SELECT DISTINCT id FROM (VALUES(old_card_id),(new_card_id)) AS ids(id) WHERE id IS NOT NULL LOOP + PERFORM 1 FROM logistics_rate_cards WHERE id=current_card_id FOR SHARE; + IF EXISTS (SELECT 1 FROM logistics_rate_card_reviews WHERE rate_card_id=current_card_id) THEN + RAISE EXCEPTION '已审核的物流费率不可改写,请建立新版本' USING ERRCODE='23514'; + END IF; + END LOOP; + IF TG_OP='DELETE' THEN RETURN OLD; END IF; + RETURN NEW; +END $$; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb4d67d..b9df676 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: '@nestjs/swagger': specifier: 12.0.1 version: 12.0.1(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@12.0.1)(reflect-metadata@0.2.2) + decimal.js: + specifier: 10.6.0 + version: 10.6.0 helmet: specifier: 8.3.0 version: 8.3.0 @@ -72,6 +75,12 @@ importers: '@types/node': specifier: 26.3.0 version: 26.3.0 + '@types/pg': + specifier: 8.23.1 + version: 8.23.1 + pg: + specifier: 8.23.0 + version: 8.23.0 tsx: specifier: 4.23.13 version: 4.23.13 @@ -1965,6 +1974,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + define-lazy-prop@2.0.0: resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} engines: {node: '>=8'} @@ -4864,6 +4876,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + define-lazy-prop@2.0.0: {} defu@6.1.7: {} diff --git a/turbo.json b/turbo.json index 9f0fa36..356ab06 100644 --- a/turbo.json +++ b/turbo.json @@ -7,7 +7,8 @@ }, "dev": { "cache": false, - "persistent": true + "persistent": true, + "dependsOn": ["^build"] }, "lint": { "dependsOn": ["^lint"]