import type { CommissionDetails } from './category-commissions'; 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 = { commission_details?: CommissionDetails; 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, ); }