feat(pricing): 开发定价方案

This commit is contained in:
R524809
2026-09-28 17:34:33 +08:00
parent a29dd92676
commit 84b230e69a
54 changed files with 6264 additions and 236 deletions
+3
View File
@@ -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';
+81
View File
@@ -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;
};
+273
View File
@@ -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,
);
}
+17 -2
View File
@@ -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();