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