Files
atlas-seller-kit/packages/contracts/src/pricing.ts
T

276 lines
9.1 KiB
TypeScript

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<typeof pricingParametersSchema>;
export type PricingPreviewRequest = z.infer<typeof pricingPreviewRequestSchema>;
export type PricingSelection = z.infer<typeof pricingSelectionSchema>;
export type PricingPolicyRequest = z.infer<typeof pricingPolicyRequestSchema>;
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,
);
}