276 lines
9.1 KiB
TypeScript
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,
|
|
);
|
|
}
|