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();
@@ -0,0 +1,51 @@
SET search_path TO atlas, public;
ALTER TABLE logistics_product_types ADD COLUMN min_actual_weight_inclusive BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE pricing_quotes ADD COLUMN product_id UUID, ADD COLUMN profile_id UUID, ADD COLUMN input_hash TEXT;
ALTER TABLE pricing_quotes ADD CONSTRAINT pricing_quote_variant_product_fk
FOREIGN KEY (workspace_id, product_variant_id, product_id) REFERENCES product_variants(workspace_id,id,product_id);
ALTER TABLE pricing_quotes ADD CONSTRAINT pricing_quote_profile_product_fk
FOREIGN KEY (workspace_id,profile_id,product_id,platform_id) REFERENCES product_platform_profiles(workspace_id,id,product_id,platform_id);
ALTER TABLE pricing_quotes ADD CONSTRAINT pricing_quote_binding_complete CHECK (
(product_variant_id IS NULL AND product_id IS NULL AND profile_id IS NULL) OR
(product_variant_id IS NOT NULL AND product_id IS NOT NULL AND profile_id IS NOT NULL));
ALTER TABLE pricing_quotes ADD CONSTRAINT pricing_quote_listing_identity UNIQUE(workspace_id,id,profile_id,product_variant_id,store_id,platform_id);
ALTER TABLE pricing_quote_options ADD CONSTRAINT pricing_option_identity UNIQUE(workspace_id,quote_id,id);
ALTER TABLE pricing_quotes DROP CONSTRAINT fk_pricing_quotes_selected_option;
ALTER TABLE pricing_quotes ADD CONSTRAINT pricing_quote_selected_option_fk
FOREIGN KEY(workspace_id,id,selected_option_id) REFERENCES pricing_quote_options(workspace_id,quote_id,id) DEFERRABLE INITIALLY DEFERRED;
ALTER TABLE product_variant_platform_listings
ADD COLUMN pricing_quote_id UUID,
ADD COLUMN pricing_draft JSONB,
ADD COLUMN pricing_valid BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE product_variant_platform_listings ADD CONSTRAINT listing_pricing_quote_fk
FOREIGN KEY(workspace_id,pricing_quote_id,profile_id,product_variant_id,store_id,platform_id)
REFERENCES pricing_quotes(workspace_id,id,profile_id,product_variant_id,store_id,platform_id) DEFERRABLE INITIALLY DEFERRED;
CREATE TABLE pricing_previews (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL REFERENCES workspaces(id),
user_id UUID NOT NULL REFERENCES users(id), store_id UUID NOT NULL, platform_id UUID NOT NULL,
input JSONB NOT NULL, input_hash TEXT NOT NULL, rules_hash TEXT NOT NULL, result JSONB NOT NULL,
expires_at TIMESTAMPTZ NOT NULL DEFAULT now()+interval '15 minutes', created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
FOREIGN KEY(workspace_id,store_id,platform_id) REFERENCES stores(workspace_id,id,platform_id),
UNIQUE(workspace_id,id)
);
CREATE INDEX pricing_previews_expiry ON pricing_previews(expires_at);
ALTER TABLE pricing_previews ENABLE ROW LEVEL SECURITY;
ALTER TABLE pricing_previews FORCE ROW LEVEL SECURITY;
CREATE POLICY workspace_isolation ON pricing_previews USING(workspace_id=app_current_workspace_id()) WITH CHECK(workspace_id=app_current_workspace_id());
-- 通用商品输入影响所有平台报价,其他平台必须同步失效;当前保存可在复核后恢复有效状态。
CREATE FUNCTION invalidate_variant_pricing() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_TABLE_NAME='product_variants' THEN
UPDATE product_variant_platform_listings SET pricing_valid=false WHERE product_variant_id=NEW.id;
ELSE
UPDATE product_variant_platform_listings SET pricing_valid=false WHERE product_id=NEW.id;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER variant_pricing_changed AFTER UPDATE OF purchase_cost_cny,package_length_mm,package_width_mm,package_height_mm,shipping_weight_g,package_spec_status ON product_variants
FOR EACH ROW WHEN (ROW(OLD.purchase_cost_cny,OLD.package_length_mm,OLD.package_width_mm,OLD.package_height_mm,OLD.shipping_weight_g,OLD.package_spec_status) IS DISTINCT FROM ROW(NEW.purchase_cost_cny,NEW.package_length_mm,NEW.package_width_mm,NEW.package_height_mm,NEW.shipping_weight_g,NEW.package_spec_status)) EXECUTE FUNCTION invalidate_variant_pricing();
CREATE TRIGGER product_pricing_changed AFTER UPDATE OF default_package_length_mm,default_package_width_mm,default_package_height_mm,default_shipping_weight_g,package_spec_status ON products
FOR EACH ROW WHEN (ROW(OLD.default_package_length_mm,OLD.default_package_width_mm,OLD.default_package_height_mm,OLD.default_shipping_weight_g,OLD.package_spec_status) IS DISTINCT FROM ROW(NEW.default_package_length_mm,NEW.default_package_width_mm,NEW.default_package_height_mm,NEW.default_shipping_weight_g,NEW.package_spec_status)) EXECUTE FUNCTION invalidate_variant_pricing();
@@ -0,0 +1,26 @@
SET search_path TO atlas, public;
-- 固定函数查找路径,业务连接不依赖迁移会话的 search_path。
ALTER FUNCTION invalidate_variant_pricing() SET search_path = atlas, public;
-- 防止数据库绕过接口拼接其他费率卡的运输规则。
CREATE FUNCTION check_pricing_transport_card() RETURNS trigger LANGUAGE plpgsql SET search_path = atlas, public AS $$
BEGIN
IF TG_TABLE_NAME='logistics_rate_rules' THEN
IF NOT EXISTS (SELECT 1 FROM logistics_product_types p JOIN logistics_transport_methods t ON t.rate_card_id=p.rate_card_id WHERE p.id=NEW.product_type_id AND t.id=NEW.transport_method_id) THEN
RAISE EXCEPTION '物流类型和运输方式必须属于同一费率卡' USING ERRCODE='23514';
END IF;
ELSIF TG_TABLE_NAME='pricing_policies' THEN
IF NEW.default_transport_method_id IS NOT NULL AND NOT EXISTS(SELECT 1 FROM logistics_transport_methods WHERE id=NEW.default_transport_method_id AND rate_card_id=NEW.rate_card_id) THEN
RAISE EXCEPTION '方案默认运输方式必须属于费率卡' USING ERRCODE='23514';
END IF;
ELSE
IF NOT EXISTS(SELECT 1 FROM pricing_policies p JOIN logistics_transport_methods t ON t.rate_card_id=p.rate_card_id WHERE p.id=NEW.pricing_policy_id AND p.workspace_id=NEW.workspace_id AND t.id=NEW.transport_method_id) THEN
RAISE EXCEPTION '方案比较方式必须属于费率卡' USING ERRCODE='23514';
END IF;
END IF;
RETURN NEW;
END $$;
CREATE CONSTRAINT TRIGGER logistics_transport_card AFTER INSERT OR UPDATE ON logistics_rate_rules DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_pricing_transport_card();
CREATE CONSTRAINT TRIGGER policy_transport_card AFTER INSERT OR UPDATE ON pricing_policies DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_pricing_transport_card();
CREATE CONSTRAINT TRIGGER policy_comparison_card AFTER INSERT OR UPDATE ON pricing_policy_transport_methods DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_pricing_transport_card();
@@ -0,0 +1,18 @@
SET search_path TO atlas, public;
ALTER TABLE product_variant_platform_listings ADD CONSTRAINT valid_pricing_requires_quote CHECK(NOT pricing_valid OR pricing_quote_id IS NOT NULL);
-- 确认后只新增报价切换引用,不允许修改历史金额或规则快照。
CREATE FUNCTION prevent_confirmed_pricing_mutation() RETURNS trigger LANGUAGE plpgsql SET search_path = atlas, public AS $$
BEGIN
IF TG_TABLE_NAME='pricing_quotes' THEN
IF OLD.status='confirmed' THEN RAISE EXCEPTION '已确认报价不可改写' USING ERRCODE='23514'; END IF;
ELSE
IF EXISTS(SELECT 1 FROM pricing_quotes WHERE id=OLD.quote_id AND workspace_id=OLD.workspace_id AND status='confirmed') THEN
RAISE EXCEPTION '已确认候选快照不可改写' USING ERRCODE='23514';
END IF;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER confirmed_quote_immutable BEFORE UPDATE ON pricing_quotes FOR EACH ROW EXECUTE FUNCTION prevent_confirmed_pricing_mutation();
CREATE TRIGGER confirmed_option_immutable BEFORE UPDATE ON pricing_quote_options FOR EACH ROW EXECUTE FUNCTION prevent_confirmed_pricing_mutation();
@@ -0,0 +1,9 @@
SET search_path TO atlas, public;
CREATE FUNCTION invalidate_profile_pricing() RETURNS trigger LANGUAGE plpgsql SET search_path = atlas, public AS $$
BEGIN
UPDATE product_variant_platform_listings SET pricing_valid=false WHERE profile_id=NEW.id;
RETURN NEW;
END $$;
CREATE TRIGGER profile_pricing_changed AFTER UPDATE OF target_store_id,platform_product_category_id,fulfillment ON product_platform_profiles
FOR EACH ROW WHEN (ROW(OLD.target_store_id,OLD.platform_product_category_id,OLD.fulfillment) IS DISTINCT FROM ROW(NEW.target_store_id,NEW.platform_product_category_id,NEW.fulfillment)) EXECUTE FUNCTION invalidate_profile_pricing();
@@ -0,0 +1,37 @@
SET search_path TO atlas, public;
-- 来源为仓库 CEL 待复核表;仅建立草稿目录,禁止自动启用或填入虚构审核人。
INSERT INTO logistics_providers(code,name,status) VALUES('CEL','CEL','active') ON CONFLICT(code) DO NOTHING;
INSERT INTO logistics_rate_cards(provider_id,platform_id,fulfillment,market_code,version,source_name,status,effective_from)
SELECT p.id,f.id,'rfbs','RU','cel-rfbs-review-2026-09','CEL Ozon rFBS · 待真实账单复核','draft',now()
FROM logistics_providers p CROSS JOIN platforms f WHERE p.code='CEL' AND upper(f.code::text)='OZON'
ON CONFLICT(provider_id,platform_id,fulfillment,market_code,version) DO NOTHING;
INSERT INTO logistics_transport_methods(rate_card_id,code,name,status)
SELECT c.id,v.code,v.name,'active' FROM logistics_rate_cards c CROSS JOIN (VALUES('express','特快'),('standard','标准'),('economy','经济')) v(code,name)
WHERE c.version='cel-rfbs-review-2026-09' ON CONFLICT(rate_card_id,code) DO NOTHING;
INSERT INTO logistics_product_types(rate_card_id,code,name,merchandise_currency_code,min_merchandise_value,max_merchandise_value,min_value_inclusive,max_value_inclusive,min_actual_weight_kg,min_actual_weight_inclusive,max_actual_weight_kg,max_sum_dimensions_cm,max_longest_side_cm,max_second_longest_side_cm,billing_basis,volume_divisor,volume_round_scale,billing_round_scale,max_volume_weight_kg,volume_limit_round_scale)
SELECT c.id,v.code,v.code,'CNY',v.min_value,v.max_value,false,true,v.min_weight,v.min_inclusive,v.max_weight,v.max_sum,v.max_side,v.max_second,v.basis::billing_weight_basis,v.divisor,v.round_scale,3,v.max_volume,v.limit_scale
FROM logistics_rate_cards c CROSS JOIN (VALUES
('ExtraSmall',NULL::numeric,135,0.001,true,0.5,90,60,NULL::numeric,'actual_only',NULL::numeric,NULL::integer,NULL::numeric,NULL::integer),
('Budget',NULL,135,0.5,false,30,150,60,NULL,'actual_only',NULL,NULL,NULL,NULL),
('Small',135,635,0.001,true,2,150,60,NULL,'actual_only',NULL,NULL,NULL,NULL),
('Big',135,635,2,false,30,310,150,NULL,'max_actual_volume',12000,3,31,0),
('PremiumSmall',635,22525,0.001,true,5,250,150,NULL,'actual_only',NULL,NULL,NULL,NULL),
('PremiumBig',635,22525,5,false,30,310,150,80,'max_actual_volume',12000,3,80,0)
) v(code,min_value,max_value,min_weight,min_inclusive,max_weight,max_sum,max_side,max_second,basis,divisor,round_scale,max_volume,limit_scale)
WHERE c.version='cel-rfbs-review-2026-09' ON CONFLICT(rate_card_id,code) DO NOTHING;
INSERT INTO logistics_rate_rules(product_type_id,transport_method_id,fixed_fee_cny,per_billable_kg_fee_cny,status)
SELECT t.id,m.id,v.fixed_fee,v.per_kg,'draft' FROM logistics_product_types t
JOIN logistics_rate_cards c ON c.id=t.rate_card_id JOIN logistics_transport_methods m ON m.rate_card_id=c.id
JOIN (VALUES
('ExtraSmall','express',3.12,46.8),('ExtraSmall','standard',3.12,36.4),('ExtraSmall','economy',3.12,26),
('Budget','express',23.92,34.32),('Budget','standard',23.92,26),('Budget','economy',23.92,17.68),
('Small','express',16.64,46.8),('Small','standard',16.64,36.4),('Small','economy',16.64,26),
('Big','standard',37.44,26),('Big','economy',37.44,17.68),
('PremiumSmall','express',22.88,46.8),('PremiumSmall','standard',22.88,36.4),('PremiumSmall','economy',22.88,26),
('PremiumBig','standard',64.48,29.12),('PremiumBig','economy',64.48,23.92)
) v(type_code,transport_code,fixed_fee,per_kg) ON t.code=v.type_code AND m.code=v.transport_code
WHERE c.version='cel-rfbs-review-2026-09' ON CONFLICT(product_type_id,transport_method_id) DO NOTHING;
@@ -0,0 +1,10 @@
SET search_path TO atlas, public;
-- 币种变化影响售价含义;历史快照保留,发布必须重新确认。
CREATE FUNCTION invalidate_store_currency_pricing() RETURNS trigger LANGUAGE plpgsql SET search_path = atlas, public AS $$
BEGIN
UPDATE product_variant_platform_listings SET pricing_valid=false WHERE store_id=NEW.id;
RETURN NEW;
END $$;
CREATE TRIGGER store_currency_pricing_changed AFTER UPDATE OF currency_code ON stores
FOR EACH ROW WHEN (OLD.currency_code IS DISTINCT FROM NEW.currency_code) EXECUTE FUNCTION invalidate_store_currency_pricing();
@@ -0,0 +1,20 @@
SET search_path TO atlas, public;
-- 平台费率审核是系统级事实;证据与复算样本随启用事务一起保存。
CREATE TABLE logistics_rate_card_reviews (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
rate_card_id UUID NOT NULL UNIQUE REFERENCES logistics_rate_cards(id) ON DELETE RESTRICT,
reviewer_user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
idempotency_key UUID NOT NULL,
request_hash CHAR(64) NOT NULL,
rules_hash CHAR(64) NOT NULL,
source_reference TEXT NOT NULL CHECK (length(trim(source_reference)) BETWEEN 3 AND 500),
review_note TEXT NOT NULL CHECK (length(trim(review_note)) BETWEEN 20 AND 2000),
samples JSONB NOT NULL CHECK (jsonb_typeof(samples) = 'array' AND jsonb_array_length(samples) >= 6),
effective_from TIMESTAMPTZ NOT NULL,
reviewed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (reviewer_user_id, idempotency_key)
);
-- 审核记录不可改写;后续资费变化须建立新费率版本并重新审核。
REVOKE UPDATE, DELETE ON logistics_rate_card_reviews FROM atlas_app;
@@ -0,0 +1,37 @@
SET search_path TO atlas, public;
-- 审核通过的规则只能由新版本替代;应用角色不能直接修改已经用于报价的运价。
CREATE FUNCTION guard_reviewed_logistics_mutation() RETURNS trigger LANGUAGE plpgsql SET search_path = atlas, public AS $$
DECLARE
card_id UUID;
BEGIN
IF TG_TABLE_NAME='logistics_rate_cards' THEN
card_id := OLD.id;
ELSIF TG_TABLE_NAME IN ('logistics_product_types','logistics_transport_methods') THEN
IF TG_OP='DELETE' THEN card_id := OLD.rate_card_id;
ELSE card_id := NEW.rate_card_id; END IF;
ELSE
IF TG_OP='DELETE' THEN
SELECT rate_card_id INTO card_id FROM logistics_product_types WHERE id=OLD.product_type_id;
ELSE
SELECT rate_card_id INTO card_id FROM logistics_product_types WHERE id=NEW.product_type_id;
END IF;
END IF;
IF card_id IS NOT NULL THEN
PERFORM 1 FROM logistics_rate_cards WHERE id=card_id FOR SHARE;
IF EXISTS (SELECT 1 FROM logistics_rate_card_reviews WHERE rate_card_id=card_id) THEN
RAISE EXCEPTION '已审核的物流费率不可改写,请建立新版本' USING ERRCODE='23514';
END IF;
END IF;
IF TG_OP='DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END $$;
CREATE TRIGGER reviewed_card_immutable BEFORE UPDATE OR DELETE ON logistics_rate_cards
FOR EACH ROW EXECUTE FUNCTION guard_reviewed_logistics_mutation();
CREATE TRIGGER reviewed_type_immutable BEFORE INSERT OR UPDATE OR DELETE ON logistics_product_types
FOR EACH ROW EXECUTE FUNCTION guard_reviewed_logistics_mutation();
CREATE TRIGGER reviewed_method_immutable BEFORE INSERT OR UPDATE OR DELETE ON logistics_transport_methods
FOR EACH ROW EXECUTE FUNCTION guard_reviewed_logistics_mutation();
CREATE TRIGGER reviewed_rule_immutable BEFORE INSERT OR UPDATE OR DELETE ON logistics_rate_rules
FOR EACH ROW EXECUTE FUNCTION guard_reviewed_logistics_mutation();
@@ -0,0 +1,31 @@
SET search_path TO atlas, public;
-- 更新规则归属时同时检查原费率卡和目标费率卡,避免把已审核规则移出后改写。
CREATE OR REPLACE FUNCTION guard_reviewed_logistics_mutation() RETURNS trigger LANGUAGE plpgsql SET search_path = atlas, public AS $$
DECLARE
old_card_id UUID;
new_card_id UUID;
current_card_id UUID;
BEGIN
IF TG_TABLE_NAME='logistics_rate_cards' THEN
old_card_id := OLD.id;
ELSIF TG_TABLE_NAME IN ('logistics_product_types','logistics_transport_methods') THEN
IF TG_OP<>'INSERT' THEN old_card_id := OLD.rate_card_id; END IF;
IF TG_OP<>'DELETE' THEN new_card_id := NEW.rate_card_id; END IF;
ELSE
IF TG_OP<>'INSERT' THEN
SELECT rate_card_id INTO old_card_id FROM logistics_product_types WHERE id=OLD.product_type_id;
END IF;
IF TG_OP<>'DELETE' THEN
SELECT rate_card_id INTO new_card_id FROM logistics_product_types WHERE id=NEW.product_type_id;
END IF;
END IF;
FOR current_card_id IN SELECT DISTINCT id FROM (VALUES(old_card_id),(new_card_id)) AS ids(id) WHERE id IS NOT NULL LOOP
PERFORM 1 FROM logistics_rate_cards WHERE id=current_card_id FOR SHARE;
IF EXISTS (SELECT 1 FROM logistics_rate_card_reviews WHERE rate_card_id=current_card_id) THEN
RAISE EXCEPTION '已审核的物流费率不可改写,请建立新版本' USING ERRCODE='23514';
END IF;
END LOOP;
IF TG_OP='DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END $$;