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
@@ -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 $$;