feat(pricing): 开发定价方案
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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<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 = {
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user