diff --git a/sql/import_oms_existing_opportunities_pg17.sql b/sql/import_oms_existing_opportunities_pg17.sql new file mode 100644 index 00000000..6d5d3328 --- /dev/null +++ b/sql/import_oms_existing_opportunities_pg17.sql @@ -0,0 +1,507 @@ +-- Import OMS existing project-space opportunities into CRM. +-- +-- Source workbook: /Users/kangwenjing/Downloads/OMS存量项目空间(研发转录CRM)0423.xlsx +-- Source sheet: OMS已立项 +-- +-- Re-runnable behavior: +-- - crm_customer is reused by generated customer_code, or by customer_name + default owner/null owner. +-- - crm_opportunity is upserted by opportunity_code, which comes from OMS 项目ID. +-- - The Excel 售前ID/售前 columns are imported as pre_sales_id/pre_sales_name. +-- - owner_user_id is still required by crm_opportunity, so this script uses a default enabled user. +-- +-- Mapping notes: +-- - 项目ID -> crm_opportunity.opportunity_code +-- - 项目名称 -> crm_opportunity.opportunity_name +-- - 最终客户名称/区域/行业 -> crm_customer +-- - 区域 -> crm_customer.province and crm_opportunity.project_location, normalized with 省/市/自治区 suffix +-- - 售前ID/售前 -> crm_opportunity.pre_sales_id/pre_sales_name +-- - 下单时间 -> crm_opportunity.expected_close_date +-- - 项目状态 = 已下单 -> stage/status won, archived true +-- - 项目状态 = 重点跟进 -> stage S4A when dictionary exists, otherwise business_negotiation +-- - 项目状态 = 商机 -> stage S0 when dictionary exists, otherwise initial_contact +-- +-- If your SQL client reports "current transaction is aborted", run this first: +-- rollback; +-- +-- This script intentionally avoids an explicit begin/commit so GUI SQL clients can +-- execute it statement-by-statement without getting stuck in an aborted transaction. + +rollback; + +drop table if exists tmp_oms_opportunity_import; +drop table if exists tmp_oms_area_map; +drop table if exists tmp_oms_default_owner; +drop table if exists tmp_oms_dict_values; +drop table if exists tmp_oms_stage_codes; + +create temp table tmp_oms_opportunity_import ( + row_no integer not null, + project_status varchar(50) not null, + project_id varchar(50) not null, + project_name varchar(200) not null, + confidence_pct varchar(1) not null, + customer_name varchar(200) not null, + province varchar(50), + industry varchar(50), + pre_sales_id bigint, + pre_sales_name varchar(100) not null, + amount numeric(18, 2) not null, + order_date date +) on commit preserve rows; + +insert into tmp_oms_opportunity_import ( + row_no, + project_status, + project_id, + project_name, + confidence_pct, + customer_name, + province, + industry, + pre_sales_id, + pre_sales_name, + amount, + order_date +) values + (2, '重点跟进', 'V001385', '贵州省公安厅云桌面增补项目', 'B', '贵州省公安厅', '贵州', '政府', 104, '陈畅', 71152, '2026-04-30'), + (3, '重点跟进', 'V001427', '低压电工上岗证鉴定中心设备采购项目', 'B', '东莞职业技术学院', '广东', '教育科研', 105, '杨钊', 80000, '2026-04-30'), + (4, '重点跟进', 'V001496', '江门市中心医院云终端设备的采购项目', 'B', '江门市中心医院', '广东', '医疗', 105, '杨钊', 500000, '2026-04-08'), + (5, '重点跟进', 'V001464', '兰州石化职业技术大学云桌面升级改造项目', 'B', '兰州石化职业技术大学', '甘肃', '教育科研', 128, '骆栋', 1600000, '2026-05-01'), + (6, '重点跟进', 'V001527', '池州职业技术学院云桌面', 'B', '池州职业技术学院', '安徽', '教育科研', 109, '韦东东', 2200000, null), + (7, '重点跟进', 'V001377', '马鞍山GA云桌面扩容', 'B', '马鞍山公安局', '安徽', '政府', 109, '韦东东', 100000, null), + (8, '重点跟进', 'V001454', '宁阳县职业中等专业学校云桌面续保(2026)', 'A', '宁阳县职业中等专业学校', '山东', '教育科研', 110, '魏光耀', 23850, '2026-05-01'), + (9, '重点跟进', 'V001434', '河北省电视台云桌面定制开发', 'C', '河北省电视台', '河北', '政府', 110, '魏光耀', 67200, '2026-04-01'), + (10, '重点跟进', 'V001474', '邯郸市邯山区阳光实验中学云桌面项目(2026)', 'A', '邯郸市邯山区阳光实验中学', '河北', '教育科研', 110, '魏光耀', 133000, '2026-04-01'), + (11, '重点跟进', 'V001446', '山东旅游职业学院云教室项目', 'C', '山东旅游职业学院', '山东', '教育科研', 110, '魏光耀', 529671, '2026-05-01'), + (12, '商机', 'V001253', '中国生物云桌面项目', 'B', '中国生物技术股份有限公司', '北京', '企业', 110, '魏光耀', 250000, '2026-10-01'), + (13, '商机', 'V001441', '中铁第五勘察设计院云桌面项目', 'B', '中铁第五勘察设计院集团有限公司', '北京', '企业', 110, '魏光耀', 92915, '2026-06-01'), + (14, '商机', 'V001365', '泰山玻璃纤维(太原)云桌面项目', 'B', '泰山玻璃纤维有限公司', '北京', '企业', 110, '魏光耀', 90276.5, '2026-04-03'), + (15, '商机', 'V001437', '北京生物国产化云桌面项目', 'C', '北京生物制品研究所有限责任公司', '北京', '企业', 110, '魏光耀', 91100, '2026-10-01'), + (16, '商机', 'V001445', '清华大学生命科学学院云桌面项目', 'B', '清华大学', '北京', '教育科研', 110, '魏光耀', 89032, '2026-06-01'), + (17, '重点跟进', 'V001521', '北京北重汽轮电机C3300终端扩容', 'A', '北京北重汽轮电机有限责任公司', '北京', '企业', 110, '魏光耀', 33600, '2026-04-30'), + (18, '重点跟进', 'V001507', '青岛物元半导体云桌面扩容(2026)', 'A', '物元半导体技术(青岛)有限公司', '山东', '企业', 110, '魏光耀', 217107, '2026-06-30'), + (19, '重点跟进', 'V001513', '东方物探云桌面扩容项目', 'A', '中国石油集团东方地球物理勘探有限责任公司', '北京', '电力能源', 110, '魏光耀', 69260.4, '2026-04-30'), + (20, '重点跟进', 'V001494', '天津国土资源和房屋职业学院云教室项目(终端增补)2026', 'A', '天津国土资源和房屋职业学院', '天津', '教育科研', 110, '魏光耀', 899000, '2026-06-30'), + (21, '重点跟进', 'V001530', '山东博源精密机械云桌面', 'B', '山东博源精密机械有限公司', '山东', '企业', 110, '魏光耀', 758400, '2026-06-30'), + (22, '重点跟进', 'V001153', '中建集团云桌面项目', 'A', '中国建筑集团有限公司', '北京', '企业', 110, '魏光耀', 1292280, '2026-06-30'), + (23, '重点跟进', 'V001166', '教育考试院云桌面项目', 'A', '教育部教育考试院', '北京', '教育科研', 110, '魏光耀', 84965, '2026-06-30'), + (24, '商机', 'V001313', '吉林移动云桌面扩容', 'A', '吉林移动', '吉林', '企业', 106, '杨坤融', 890000, '2026-06-12'), + (25, '重点跟进', 'V001399', '佳木斯大学云桌面项目', 'B', '佳木斯大学', '内蒙古', '教育科研', 105, '杨钊', 895210, '2026-05-15'), + (26, '商机', 'V001523', '雅江集团云桌面项目', 'C', '中国雅江集团有限公司', '四川', '企业', 105, '杨钊', 5000000, '2026-04-30'), + (27, '商机', 'V001515', '福建闽侯公安', 'B', '闽侯公安局', '福建', '政府', 130, '张锦杰', 150000, '2026-04-30'), + (28, '商机', 'V001435', '东亚银行Citrix桌面云国产化项目', 'B', '上海东亚银行', '上海', '金融', 130, '张锦杰', 300000, '2026-06-01'), + (29, '商机', 'V001522', '上海泽丰半导体云桌面项目', 'C', '上海泽丰半导体', '上海', '企业', 130, '张锦杰', 800000, '2026-06-01'), + (30, '商机', 'V001416', '海南移动自研1500点桌面云', 'C', '海南省移动', '海南', '企业', 130, '张锦杰', 4500000, '2026-06-30'), + (31, '重点跟进', 'V000001', '江西省余江区总医院人民医院项目', 'A', '江西省余江区总医院人民医院', '江西', '医疗', 130, '张锦杰', 11000, '2026-04-02'), + (32, '重点跟进', 'V001281', '厦门翔安机场口岸通关港口', 'C', '厦门翔安机场', '福建', '企业', 130, '张锦杰', 310000, null), + (33, '重点跟进', 'V001299', '抚州公安300点VDI利旧', 'A', '抚州市公安局', '江西', '政府', 130, '张锦杰', 300000, '2026-04-30'), + (34, '重点跟进', 'V001493', '江西新建区人民医院-二期扩容', 'B', '江西新建区人民医院', '江西', '医疗', 130, '张锦杰', 422830, '2026-05-15'); + +create temp table tmp_oms_default_owner ( + owner_user_id bigint not null, + owner_source text not null +) on commit preserve rows; + +create temp table tmp_oms_area_map ( + raw_area varchar(50) not null primary key, + normalized_area varchar(50) not null +) on commit preserve rows; + +create temp table tmp_oms_stage_codes ( + initial_stage varchar(50) not null, + follow_stage varchar(50) not null +) on commit preserve rows; + +create temp table tmp_oms_dict_values ( + opportunity_type varchar(50) not null +) on commit preserve rows; + +insert into tmp_oms_stage_codes (initial_stage, follow_stage) +values ('initial_contact', 'business_negotiation'); + +insert into tmp_oms_dict_values (opportunity_type) +values ('新建'); + +insert into tmp_oms_area_map (raw_area, normalized_area) +select distinct + province as raw_area, + case province + when '北京' then '北京市' + when '上海' then '上海市' + when '天津' then '天津市' + when '重庆' then '重庆市' + when '内蒙古' then '内蒙古自治区' + when '广西' then '广西壮族自治区' + when '西藏' then '西藏自治区' + when '宁夏' then '宁夏回族自治区' + when '新疆' then '新疆维吾尔自治区' + when '香港' then '香港特别行政区' + when '澳门' then '澳门特别行政区' + when '河北' then '河北省' + when '山西' then '山西省' + when '辽宁' then '辽宁省' + when '吉林' then '吉林省' + when '黑龙江' then '黑龙江省' + when '江苏' then '江苏省' + when '浙江' then '浙江省' + when '安徽' then '安徽省' + when '福建' then '福建省' + when '江西' then '江西省' + when '山东' then '山东省' + when '河南' then '河南省' + when '湖北' then '湖北省' + when '湖南' then '湖南省' + when '广东' then '广东省' + when '海南' then '海南省' + when '四川' then '四川省' + when '贵州' then '贵州省' + when '云南' then '云南省' + when '陕西' then '陕西省' + when '甘肃' then '甘肃省' + when '青海' then '青海省' + when '台湾' then '台湾省' + else province + end as normalized_area +from tmp_oms_opportunity_import +where province is not null + and btrim(province) <> ''; + +do $$ +declare + uid_column text; + username_filter text := ''; + status_filter text := ''; + deleted_filter text := ''; + default_owner_sql text; + invalid_confidence text; + duplicate_project_ids text; +begin + select string_agg(format('第%s行:%s', row_no, confidence_pct), ', ' order by row_no) + into invalid_confidence + from tmp_oms_opportunity_import + where confidence_pct not in ('A', 'B', 'C'); + + if invalid_confidence is not null then + raise exception 'OMS存量商机导入失败:把握度仅支持 A/B/C,异常数据:%', invalid_confidence; + end if; + + select string_agg(project_id, ', ' order by project_id) + into duplicate_project_ids + from ( + select project_id + from tmp_oms_opportunity_import + group by project_id + having count(*) > 1 + ) duplicated; + + if duplicate_project_ids is not null then + raise exception 'OMS存量商机导入失败:项目ID重复,无法按 opportunity_code 幂等导入:%', duplicate_project_ids; + end if; + + if to_regclass('public.sys_user') is null then + raise exception 'OMS存量商机导入失败:sys_user 表不存在,无法确定商机默认负责人 owner_user_id'; + end if; + + select case + when exists ( + select 1 + from information_schema.columns + where table_schema = 'public' + and table_name = 'sys_user' + and column_name = 'user_id' + ) then 'user_id' + when exists ( + select 1 + from information_schema.columns + where table_schema = 'public' + and table_name = 'sys_user' + and column_name = 'id' + ) then 'id' + else null + end + into uid_column; + + if uid_column is null then + raise exception 'OMS存量商机导入失败:sys_user 缺少 user_id/id 字段,无法写入 owner_user_id'; + end if; + + if exists ( + select 1 + from information_schema.columns + where table_schema = 'public' + and table_name = 'sys_user' + and column_name = 'status' + ) then + status_filter := ' and coalesce(u.status, 1) = 1'; + end if; + + if exists ( + select 1 + from information_schema.columns + where table_schema = 'public' + and table_name = 'sys_user' + and column_name = 'is_deleted' + ) then + deleted_filter := ' and coalesce(u.is_deleted, 0) = 0'; + end if; + + if exists ( + select 1 + from information_schema.columns + where table_schema = 'public' + and table_name = 'sys_user' + and column_name = 'username' + ) then + username_filter := 'case when btrim(coalesce(u.username::text, '''')) = ''admin'' then 0 else 1 end,'; + end if; + + default_owner_sql := format( + 'insert into tmp_oms_default_owner (owner_user_id, owner_source) + select u.%1$I::bigint, ''default sys_user for OMS import'' + from public.sys_user u + where u.%1$I is not null%2$s%3$s + order by %4$s u.%1$I asc + limit 1', + uid_column, + status_filter, + deleted_filter, + username_filter + ); + + execute default_owner_sql; + + if not exists (select 1 from tmp_oms_default_owner) then + raise exception 'OMS存量商机导入失败:未找到可用的默认商机负责人,请先确认 sys_user 中存在启用用户'; + end if; +end $$; + +do $$ +begin + if to_regclass('public.sys_dict_item') is not null then + execute $sql$ + update tmp_oms_stage_codes + set initial_stage = coalesce(( + select s.item_value + from public.sys_dict_item s + where s.type_code = 'sj_xmjd' + and s.item_value = 'S0' + and coalesce(s.status, 1) = 1 + and coalesce(s.is_deleted, 0) = 0 + limit 1 + ), initial_stage), + follow_stage = coalesce(( + select s.item_value + from public.sys_dict_item s + where s.type_code = 'sj_xmjd' + and s.item_value = 'S4A' + and coalesce(s.status, 1) = 1 + and coalesce(s.is_deleted, 0) = 0 + limit 1 + ), follow_stage) + $sql$; + + execute $sql$ + update tmp_oms_dict_values + set opportunity_type = coalesce(( + select s.item_value + from public.sys_dict_item s + where s.type_code = 'sj_jslx' + and s.item_label = '新建' + and coalesce(s.status, 1) = 1 + and coalesce(s.is_deleted, 0) = 0 + order by s.sort_order asc nulls last + limit 1 + ), opportunity_type) + $sql$; + end if; + + if to_regclass('public.cnarea') is not null then + execute $sql$ + update tmp_oms_area_map m + set normalized_area = coalesce(( + select c.name + from public.cnarea c + where c.level = 1 + and ( + c.name = m.raw_area + or c.short_name = m.raw_area + or replace(c.name, '省', '') = m.raw_area + or replace(replace(replace(replace(replace(c.name, '市', ''), '自治区', ''), '壮族', ''), '回族', ''), '维吾尔', '') = m.raw_area + ) + order by c.area_code asc, c.id asc + limit 1 + ), m.normalized_area) + $sql$; + end if; +end $$; + +with customer_source as ( + select distinct on (t.customer_name, d.owner_user_id) + 'OMS-CUS-' || substr(md5(btrim(t.customer_name) || ':' || d.owner_user_id::text), 1, 16) as customer_code, + t.customer_name, + t.industry, + coalesce(am.normalized_area, t.province) as province, + d.owner_user_id + from tmp_oms_opportunity_import t + left join tmp_oms_area_map am on am.raw_area = t.province + cross join tmp_oms_default_owner d + order by t.customer_name, d.owner_user_id, t.row_no +) +insert into crm_customer ( + customer_code, + customer_name, + customer_type, + industry, + province, + owner_user_id, + source, + status, + remark, + created_at, + updated_at +) +select + cs.customer_code, + cs.customer_name, + null, + cs.industry, + cs.province, + cs.owner_user_id, + 'OMS存量导入', + 'following', + '来自 OMS 存量项目空间(研发转录CRM)0423', + now(), + now() +from customer_source cs +where not exists ( + select 1 + from crm_customer c + where btrim(c.customer_name) = btrim(cs.customer_name) + and (c.owner_user_id = cs.owner_user_id or c.owner_user_id is null) +) +on conflict (customer_code) do update +set customer_name = excluded.customer_name, + industry = coalesce(nullif(crm_customer.industry, ''), excluded.industry), + province = coalesce(nullif(crm_customer.province, ''), excluded.province), + owner_user_id = coalesce(crm_customer.owner_user_id, excluded.owner_user_id), + source = coalesce(nullif(crm_customer.source, ''), excluded.source), + updated_at = now(); + +insert into crm_opportunity ( + opportunity_code, + opportunity_name, + customer_id, + owner_user_id, + project_location, + operator_name, + amount, + expected_close_date, + confidence_pct, + stage, + opportunity_type, + product_type, + source, + sales_expansion_id, + channel_expansion_id, + pre_sales_id, + pre_sales_name, + competitor_name, + archived, + archived_at, + pushed_to_oms, + oms_push_time, + description, + status, + created_at, + updated_at +) +select + t.project_id, + t.project_name, + customer_match.id, + d.owner_user_id, + coalesce(am.normalized_area, t.province), + null, + t.amount, + t.order_date, + t.confidence_pct, + case + when t.project_status = '已下单' then 'won' + when t.project_status = '重点跟进' then sc.follow_stage + else sc.initial_stage + end, + dv.opportunity_type, + 'VDI云桌面', + 'OMS存量导入', + null, + null, + t.pre_sales_id, + t.pre_sales_name, + null, + t.project_status = '已下单', + case when t.project_status = '已下单' then coalesce(t.order_date::timestamptz, now()) else null end, + true, + now(), + concat_ws(E'\n', + 'OMS存量项目空间(研发转录CRM)0423导入', + '原项目状态:' || t.project_status, + '原项目ID:' || t.project_id + ), + case when t.project_status = '已下单' then 'won' else 'active' end, + now(), + now() +from tmp_oms_opportunity_import t +left join tmp_oms_area_map am on am.raw_area = t.province +cross join tmp_oms_default_owner d +cross join tmp_oms_stage_codes sc +cross join tmp_oms_dict_values dv +join lateral ( + select c.id + from crm_customer c + where c.customer_code = 'OMS-CUS-' || substr(md5(btrim(t.customer_name) || ':' || d.owner_user_id::text), 1, 16) + or ( + btrim(c.customer_name) = btrim(t.customer_name) + and (c.owner_user_id = d.owner_user_id or c.owner_user_id is null) + ) + order by + case + when c.customer_code = 'OMS-CUS-' || substr(md5(btrim(t.customer_name) || ':' || d.owner_user_id::text), 1, 16) then 0 + when c.owner_user_id = d.owner_user_id then 1 + else 2 + end, + c.id + limit 1 +) customer_match on true +on conflict (opportunity_code) do update +set opportunity_name = excluded.opportunity_name, + customer_id = excluded.customer_id, + owner_user_id = excluded.owner_user_id, + project_location = excluded.project_location, + amount = excluded.amount, + expected_close_date = excluded.expected_close_date, + confidence_pct = excluded.confidence_pct, + stage = excluded.stage, + opportunity_type = excluded.opportunity_type, + product_type = excluded.product_type, + source = excluded.source, + pre_sales_id = excluded.pre_sales_id, + pre_sales_name = excluded.pre_sales_name, + archived = excluded.archived, + archived_at = excluded.archived_at, + pushed_to_oms = excluded.pushed_to_oms, + oms_push_time = coalesce(crm_opportunity.oms_push_time, excluded.oms_push_time), + description = excluded.description, + status = excluded.status, + updated_at = now(); + +select + count(*) as imported_rows, + count(*) filter (where project_status = '已下单') as won_rows, + count(*) filter (where project_status <> '已下单') as active_rows, + sum(amount) as total_amount +from tmp_oms_opportunity_import; + +select + owner_user_id as default_owner_user_id, + owner_source +from tmp_oms_default_owner;