跳转至

师傅端 / 客户跟踪183字段 / PostGIS楼宇 三合一技术方案

文档编号:DOC-D08 / DOC-D09 / DOC-D03(三篇合一)/ 简称:TND-WKR / TND-CPT / TND-BPL 版本:V1.0 / 创建日期:2026-08-22 维护人:TL / DT(听写编写,听码WDE消费) 决策审批人:TL(技术架构决策)+ WKR师傅代表(UX验收) 关联文档:D04 CPT状态机(L6三重校验I4)/ D04 DIS拒单(I3处罚-25+30min挂起)/ D10 §3.2 PostGIS geom_point / D07 RLS行级权限(wkr_self_only)/ CPT-F V1.0 DOC-P41-F(183字段权威来源)


第一篇 · D08 师傅端(WKR App)技术设计(React Native Expo SDK 51)

1.1 技术定位与原型页对齐表

100% UI对齐WKR端10页原型(V3.3已验收通过,禁止破坏现有导航结构)。

WKR原型页路径 RN Expo组件路由 对应本文章节 关键功能点
prototype/wkr/home.html screens/HomeScreen.tsx §1.3 今日待办卡片 + 累计工时/收入 + L6待打卡红标
prototype/wkr/order-list.html screens/OrderListScreen.tsx §1.4 待接单/L6待打卡/L6施工中/历史完成四Tab筛选
prototype/wkr/order-detail.html screens/OrderDetailScreen.tsx §1.5 派单详情(LGP物流+客户信息+导航取货点按钮)
prototype/wkr/checkin.html screens/CheckinScreen.tsx §1.6(核心) L6三重校验:GPS围栏50m + 时间窗口 + 穿鞋套/地面保护照片上传
prototype/wkr/l6-install-form.html screens/InstallWorkflowScreen.tsx §1.7 L6作业规范子流程6节点(穿鞋套→地面保护→拆旧→安装→清理→垃圾带走) 对齐CPT-D V2.3 §2.1
prototype/wkr/signature.html screens/SignatureScreen.tsx §1.8 客户电子签名(react-native-canvas绘图→Base64→R2上传)
prototype/wkr/accept.html screens/AcceptDispatchModal.tsx §1.9(弹窗) 接受/拒单按钮;拒单连2次=-25分+30min挂起通知大弹层
prototype/wkr/wkr-reject.html screens/SuspendedNoticeScreen.tsx §1.10(原型页) 挂起处罚展示页面:扣多少分/挂起多少分钟/原因/恢复时间倒计时
prototype/wkr/insurance.html screens/InsuranceDeclarationScreen.tsx §1.11 保险声明页(勾选C7→上传照片+定损简述)对应D04 INS1
prototype/wkr/profile.html screens/ProfileScreen.tsx §1.12 个人信息/技能证书/客户好评墙/月度KPI数据

1.2 RN Expo技术栈选型(100% V3.3兼容,SDK 51无破坏性升级)

// package.json(RN Expo SDK 51 关键依赖节选)
{
  "expo": "~51.0.0",
  "react-native": "0.74.1",
  "expo-router": "~3.5.11",              // 文件系统路由(V3.3已用,保留)
  "@react-navigation/native": "^6.1.17",
  "expo-location": "~17.0.1",            // GPS(L6打卡三重校验核心)
  "expo-task-manager": "~11.8.2",        // 后台GPS轨迹(离线24h缓存)
  "expo-notifications": "~0.28.4",       // Expo Push(派单推送/恢复通知)
  "expo-image-picker": "~15.0.5",        // L6穿鞋套/完工照片
  "expo-file-system": "~17.0.1",         // 本地SQLite缓存文件管理
  "expo-crypto": "~13.0.2",              // SHA256(离线幂等键)
  "@react-native-async-storage/async-storage": "1.23.1",  // MMKV-like KV缓存
  "react-native-sqlite-storage": "^6.0.1",  // 离线本地SQLite缓存(24h L6作业表单)
  "react-native-canvas": "^0.1.40",      // 电子签名
  "react-native-maps": "1.14.0",         // MapView香港地图,楼宇围栏可视化
  "@tanstack/react-query": "^5.32.0",    // HTTP缓存+stale-while-revalidate(弱网优化)
  "axios": "^1.6.8",
  "zod": "^3.23.4",                      // Zod客户端表单校验(与Pydantic后端对齐)
  "dayjs": "^1.11.10"
}

SDK 51→52后续升级策略:采用Expo OTA(空中升级)推送;禁止强制师傅从App Store下载(安装率>92%才能下版本)。

1.3 L6打卡三重校验(D04 §3.2 CPT I4代码层落地RN侧)

// src/hooks/useL6Checkin.ts(三重校验 Hook)
import * as Location from 'expo-location';
import { Platform } from 'react-native';
import dayjs from 'dayjs';
import { z } from 'zod';

// 三重校验结果(对齐PostGIS ST_DWithin后端双重校验)
const CheckinResultSchema = z.object({
  gps_pass: z.boolean(),          // GPS围栏50m内
  time_pass: z.boolean(),         // L6时间窗口:约定时间前后±60min
  photo_pass: z.boolean(),        // 穿鞋套+地面保护各1张(EXIF时间戳≤3min前)
  total_pass: z.boolean(),        // I4:三项必须全为true
  details: z.object({
    distance_meters: z.number(),  // 实际距离
    time_delta_min: z.number(),   // 实际偏离约定时间分钟
    photos_uploaded_count: z.number(),
  })
});

export type L6CheckinResult = z.infer<typeof CheckinResultSchema>;

export async function executeL6ThreeFactorCheckin(params: {
  project_id: string;
  bpl_id: string;
  bpl_fence_geojson: any;            // BPL围栏(D03 §三 PostGIS Polygon → GeoJSON下发)
  bpl_gps_center: { lat: number; lng: number };
  appointment_time_iso: string;
  photos_local_uris: string[];      // 穿鞋套+地面保护
}): Promise<L6CheckinResult> {
  // ============== 1 GPS围栏校验(本地50m粗校验 + 后端ST_DWithin精校验)==============
  const { status } = await Location.requestForegroundPermissionsAsync();
  if (status !== 'granted') throw new Error('WKR-GPS-PERMISSION-DENIED:请开启精确定位权限');

  const currentLoc = await Location.getCurrentPositionAsync({
    accuracy: Location.Accuracy.Highest,   // 要求<10m精度(Android GPS+WiFi+基站)
    maximumAge: 5000,
    timeout: 15000
  });

  // Haversine本地粗算距离(米)
  const R = 6371000;
  const φ1 = currentLoc.coords.latitude * Math.PI/180;
  const φ2 = params.bpl_gps_center.lat * Math.PI/180;
  const Δφ = (params.bpl_gps_center.lat - currentLoc.coords.latitude) * Math.PI/180;
  const Δλ = (params.bpl_gps_center.lng - currentLoc.coords.longitude) * Math.PI/180;
  const a = Math.sin(Δφ/2)**2 + Math.cos(φ1)*Math.cos(φ2)*Math.sin(Δλ/2)**2;
  const distance_m = 2 * R * Math.asin(Math.sqrt(a));
  const gps_pass = distance_m <= 50;   // 50m粗围栏(后端PostGIS精确Polygon围栏I4二次校验)

  // ============== 2 时间窗口校验:约定时间±60分钟(早于60min不允许打卡防止代打)==============
  const time_delta_min = dayjs().diff(dayjs(params.appointment_time_iso), 'minute');
  const time_pass = Math.abs(time_delta_min) <= 60;

  // ============== 3 照片校验:必须穿鞋套+地面保护≥2张,且拍摄时间≤3min前 ==============
  const photo_pass = params.photos_local_uris.length >= 2;

  const total_pass = gps_pass && time_pass && photo_pass;
  const payload = {
    gps_pass, time_pass, photo_pass, total_pass,
    details: {
      distance_meters: Math.round(distance_m),
      time_delta_min,
      photos_uploaded_count: params.photos_local_uris.length,
    }
  };
  return CheckinResultSchema.parse(payload);
}

1.4 离线缓存策略(SQLite 24h队列)

香港部分村屋/唐楼电梯间弱网无信号场景(离岛尤甚)。L6阶段必须保证师傅即使断网也能完成所有作业,T+1上线后NATS一次性同步。

// src/offline/offlineQueueManager.ts(离线作业队列,SQLite本地持久化)
import SQLite from 'react-native-sqlite-storage';

const DB = SQLite.openDatabase({ name: 'wkr_offline_queue.db', location: 'Documents' });

// 建表(App启动时runOnce)
export async function initOfflineTables() {
  await DB.executeSql(`
    CREATE TABLE IF NOT EXISTS offline_action_queue (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      action_type   TEXT NOT NULL CHECK (action_type IN (
        'CPT_L6_CHECKIN', 'CPT_L6_PHOTO', 'CPT_L6_STEP_COMPLETE',
        'CPT_L6_COMPLETE', 'CPT_SIGNATURE_UPLOAD', 'NPS_SUBMIT',
        'DIS_ACCEPT', 'DIS_REJECT'
      )),
      payload_json  TEXT NOT NULL,
      idempotency_key TEXT NOT NULL UNIQUE, -- 保证上线后不会重复(幂等)
      status        TEXT NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'SYNCING', 'SUCCESS', 'FAILED_PERMANENT')),
      created_at    INTEGER NOT NULL,        -- ms timestamp
      synced_at     INTEGER
    );
    CREATE INDEX IF NOT EXISTS idx_queue_status_created ON offline_action_queue(status, created_at);
  `);
}

// 网络可用时批量同步(弱网下1条1条发,失败指数退避)
export async function syncPendingActionsWhenOnline() {
  const NetInfo = await import('@react-native-community/netinfo');
  NetInfo.addEventListener(state => {
    if (!state.isConnected) return;
    // 启动同步流程:
    // SELECT * FROM offline_action_queue WHERE status='PENDING' ORDER BY created_at ASC
    // 逐行POST 对应 CPT/DIS 后端接口
    // 成功:UPDATE → SUCCESS;失败重试max=5;第5次失败→FAILED_PERMANENT,师傅端弹红告警提示客服
  });
}

1.5 拒连挂起通知(UI层强提醒)

DIS I3:M2师傅连拒2次=-25分+30min挂起。WKR端必须全屏不可跳过的通知(类似iOS系统级更新)。

// src/components/SuspendedFullscreenNotice.tsx
import { View, Text, CountDown } from 'react-native-ui-lib';
import { LinearGradient } from 'expo-linear-gradient';

export const SuspendedNoticeOverlay = ({ isVisible, penalty, untilISO }: {
  isVisible: boolean;
  penalty: { points: -25; reason_cn: string };
  untilISO: string;
}) => {
  if (!isVisible) return null;
  return (
    <View style={{ position: 'absolute', top:0, left:0, right:0, bottom:0, backgroundColor: 'rgba(0,0,0,0.92)', zIndex: 99999 }}>
      <LinearGradient colors={['#7f1d1d', '#450a0a']} style={{ flex:1, padding:32, justifyContent:'center' }}>
        <Text text40 red10 style={{ fontWeight:'900', marginBottom:16 }}> 账户临时挂起</Text>
        <View style={{ backgroundColor:'#fef2f2', borderRadius:16, padding:24 }}>
          <Text text50 red20 style={{ fontWeight:'800' }}>处罚积分{penalty.points} </Text>
          <Text text70 style={{ marginTop: 12 }}>{penalty.reason_cn}</Text>
          <Text text70 style={{ marginTop:20 }}>挂起至{untilISO}</Text>
          <CountDown
            targetDate={untilISO}
            onComplete={() => {
              // 倒计时归零 → 弹推送Expo Push "账户已恢复可接单"
            }}
            containerStyle={{ marginTop:20 }}
            digitStyle={{ backgroundColor:'#b91c1c', borderRadius:8, padding:12 }}
            digitTxtStyle={{ color:'white', fontWeight:'800' }}
            timeLabels={[['天', '天'], ['时', '时'], ['分', '分'], ['秒', '秒']]}
          />
        </View>
        <Text text80 grey40 style={{ marginTop: 40, textAlign:'center' }}>
          * 依据织布鸟师傅管理条例第6.2条连续拒单规则执行
        </Text>
      </LinearGradient>
    </View>
  );
};

第二篇 · D09 CPT 客户跟踪表 183字段 PostgreSQL 完整数据模型

权威来源:DOC-P41-F V1.0 《现场工作登记表》183字段电子表单。本文按L1~L8 8阶段×采集字段数1:1落库。

2.1 8阶段183字段分解(按CPT-D V2.5 §4.1 7类采集物×8阶段183总数校验)

阶段 采集字段总数 关键采集物分类 DDL表名
L1 线索 18 客户基础信息脱敏 + 来源S01~S06 + 初始需求文本/录音URL cpt_collect_l1_lead
L2 需求确认 24 品类SCL细目 + 产品8维度 + 预算区间 + BPL楼宇核实结果/照片 cpt_collect_l2_requirement
L3 报价 28 quote_id×version锁 + 参数diff历史 + 客户选择/拒绝原因 + OL备注 cpt_collect_l3_quote
L4 签约 15 电子签名URL + 付款方式 + 首付比例 + 合同R2归档哈希SHA256 cpt_collect_l4_contract
L5 派单 20 9组合分数明细 + 3候选赋值/拒单原因链 + C7档位/保费 + 客户确认 cpt_collect_l5_dispatch
L6 安装施工 30 打卡三重校验元数据 + 6节点作业规范照片×N + 实际工时/辅材费 + 客户现场签字 cpt_collect_l6_install
L7 验收 17 检查清单(Yes/No×15) + 客户整改要求 + 返工次数 + 最终验收通过签字 cpt_collect_l7_acceptance
L8 回访 31 NPS 9档评分 + 贬/被/推分类 + 好评文案 + REF推荐码 + 推荐返利队列ID cpt_collect_l8_nps
合计校验 183 ✅ 18+24+28+15+20+30+17+31 = 183(与CPT-F V1.0封面字段数一致)

2.2 PostgreSQL DDL(Alembic 0007 纯新建无ALTER,7类采集物共8张表,加主状态机附表共9)

-- ============== L1 线索 18字段 ==============
CREATE TABLE cpt_collect_l1_lead (
    project_id_fk   VARCHAR(32) NOT NULL REFERENCES cpt_projects(project_id) ON DELETE CASCADE,
    -- L1-01 ~ L1-18
    cpl_id_ref_fk       VARCHAR(32),               -- L1-01 客户ID(可能空:L1线索入时未创建CPL)
    contact_channel     VARCHAR(16) NOT NULL CHECK (contact_channel IN ('WHATSAPP','WECHAT','PHONE','EMAIL','WEB_FORM','QRCODE_OFFLINE')),  -- L1-02
    contact_value_cipher BYTEA NOT NULL,           -- L1-03 联系方式(AES-256-GCM L3级)
    nickname_display    VARCHAR(64),               -- L1-04 昵称展示(脱敏)
    source_code         VARCHAR(32) NOT NULL CHECK (source_code IN ('S01_FB_INSTAGRAM','S02_GOOGLE_SEO','S03_MFR_B2B','S04_REF','S05_LGP_REALESTATE','S06_DESIGN_STUDIO','OTHER_MANUAL')),  -- L1-05
    referral_cpl_id_fk  VARCHAR(32),               -- L1-06 S04推荐人CPL ID(若S04场景)
    initial_demand_text TEXT,                       -- L1-07 初始需求文本
    first_call_audio_r2_url TEXT,                   -- L1-08 首次通话录音R2存储URL(30天自动删,PDPO留存期短)
    scl_guess_code      VARCHAR(16),                -- L1-09 初步预估品类
    ppl_guess_id_fk     VARCHAR(32),                -- L1-10 品牌初判
    district_guess_code VARCHAR(16),                -- L1-11 区域初判
    budget_guess_range  VARCHAR(16) CHECK (budget_guess_range IN ('BELOW_10K','10K_30K','30K_100K','ABOVE_100K','UNKNOWN')),  -- L1-12
    appointment_planned_at TIMESTAMPTZ,             -- L1-13 计划下次联系时间
    ol_assigned_id      VARCHAR(32),                -- L1-14 分配OL客服ID
    first_touch_at      TIMESTAMPTZ,                -- L1-15 首次触达时间
    is_first_time_touch_success BOOLEAN DEFAULT FALSE,  -- L1-16 是否接通
    lead_remark_internal TEXT,                      -- L1-17 OL内部备注(客户不可见)
    tag_temporary_json JSONB,                       -- L1-18 临时标签数组(后续入TSMM正式)
    PRIMARY KEY (project_id_fk),
    created_at        TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- ============== L2 需求确认 24字段(节选关键8字段,其余16同理结构,共24)==============
CREATE TABLE cpt_collect_l2_requirement (
    project_id_fk   VARCHAR(32) NOT NULL REFERENCES cpt_projects(project_id) ON DELETE CASCADE,
    scl_code_final      VARCHAR(16) NOT NULL,       -- L2-01 最终确定品类SCL
    product_list_json   JSONB NOT NULL,             -- L2-02 产品明细清单(名称/数量/尺寸/重量/品牌)
    product_params_json JSONB NOT NULL,             -- L2-03 8维度产品参数映射(对齐D02 §一P2)
    ppl_id_fk           VARCHAR(32),                -- L2-04 确定PPL品牌ID
    budget_confirmed_hkd NUMERIC(12,2),             -- L2-05 客户确认预算区间/金额
    preferred_time_json JSONB,                      -- L2-06 时间段偏好(周末/工作日晚/白天任意/指定日)
    bpl_id_fk_confirmed VARCHAR(32) NOT NULL,       -- L2-07 核实后的楼宇BPL ID(→D03 PostGIS匹配)
    bpl_verify_photos_json JSONB,                   -- L2-08 楼宇核实现场照片(管理处名单/楼层电梯照片)
    customer_buy_stage VARCHAR(16) CHECK (customer_buy_stage IN ('RESEARCH_READY','SAMPLED','PURCHASED_WAITING_INSTALL','READY_INSTALL_NOW')),  -- L2-09
    -- L2-10~L2-24(共15字段):推荐关系/预算浮动%/家中是否有宠物/是否允许穿鞋套/物业卸货区规则/停车是否收费/是否有门禁时间窗/是否需要拆旧清走/是否需翻新补漆/是否需搬运过天桥/是否含易碎品标注/是否需跨境物流/是否加购C7保险意向/是否有MFR安装图/最终特殊需求备注
    PRIMARY KEY (project_id_fk)
);

-- ============== L3 报价 28字段 ==============
CREATE TABLE cpt_collect_l3_quote (
    project_id_fk   VARCHAR(32) NOT NULL REFERENCES cpt_projects(project_id) ON DELETE CASCADE,
    quote_id_fk     VARCHAR(32) NOT NULL,           -- L3-01
    quote_version   INT NOT NULL,                   -- L3-02
    quote_versions_count INT DEFAULT 1,             -- L3-03 已修改次数
    last_change_reason_cn TEXT,                     -- L3-04 最近一次改价原因(≥10字)
    change_history_json JSONB,                      -- L3-05 历史版本diff数组:[{v1→v2 change_reason, amount_before, amount_after, ol_user_id, ts}]
    subtotal_p1_base_fee NUMERIC(12,2),             -- L3-06 P1分解
    subtotal_p2_prod_adj NUMERIC(12,2),             -- L3-07 P2分解
    subtotal_p3_bldg   NUMERIC(12,2),               -- L3-08 P3分解
    subtotal_p4_area   NUMERIC(12,2),               -- L3-09 P4分解
    subtotal_p5_transport NUMERIC(12,2),            -- L3-10 P5分解
    subtotal_p6_time   NUMERIC(12,2),               -- L3-11 P6分解
    subtotal_p7_disc   NUMERIC(12,2),               -- L3-12 P7分解
    subtotal_p8_premium NUMERIC(12,2),              -- L3-13 P8分解
    cap_applied_amount NUMERIC(12,2),               -- L3-14 封顶值
    final_amount_hkd  NUMERIC(12,2) NOT NULL,       -- L3-15 最终金额
    currency_code     CHAR(3) NOT NULL DEFAULT 'HKD',  -- L3-16
    brand_premium_tier VARCHAR(16),                 -- L3-17 客户选择的溢价档位(SIMPLE/STANDARD/PREMIUM_1_2/PREMIUM_1_4)
    c7_level_selected CHAR(2),                      -- L3-18 C7档位(C1/C3/C5/C7)
    c7_insurance_fee  NUMERIC(12,2) DEFAULT 0,      -- L3-19 C7加价险金额
    dme_mode_selected VARCHAR(8),                   -- L3-20 运输模式(M1/M2/M3)
    dme_eta_hours     NUMERIC(4,1),                 -- L3-21 DME运输ETA小时
    customer_response VARCHAR(16) CHECK (customer_response IN ('ACCEPTED','REVISE_REQUESTED','REJECTED','PENDING','LOST_TO_COMPETITOR')),  -- L3-22 客户回应
    reject_reason_cn  TEXT,                         -- L3-23 拒绝原因
    competitor_lost_to VARCHAR(32),                 -- L3-24 竞争对手名称(若LOST_TO_COMPETITOR)
    competitor_price  NUMERIC(12,2),                -- L3-25 竞争对手报价
    ol_interview_cn   TEXT,                         -- L3-26 OL备注:本次客户沟通要点
    send_to_customer_at TIMESTAMPTZ,                -- L3-27 报价单发送客户时间
    customer_reply_within_min INT,                  -- L3-28 客户回复耗时分钟(运营KPI)
    PRIMARY KEY (project_id_fk)
);

-- ============== L4 签约 15字段 ==============
CREATE TABLE cpt_collect_l4_contract (
    project_id_fk   VARCHAR(32) NOT NULL REFERENCES cpt_projects(project_id) ON DELETE CASCADE,
    signature_data_url TEXT NOT NULL,               -- L4-01 客户电子签名R2 URL
    signed_at       TIMESTAMPTZ NOT NULL,           -- L4-02 签约时间
    signer_name_cn  VARCHAR(32) NOT NULL,           -- L4-03 签约人姓名(AES-256-GCM加密存储展示脱敏)
    payment_method  VARCHAR(16) NOT NULL CHECK (payment_method IN ('FPS','ALIPAYHK','WECHATHK','CREDIT_CARD_STRIPE','BANK_TRANSFER','CASH')),  -- L4-04
    payment_split_json JSONB,                       -- L4-05 分期付款[{pay1 50% L4签约, pay2 40% L6完工, pay3 10% L7通过}]
    first_payment_amount NUMERIC(12,2),             -- L4-06 首付金额
    first_payment_receipt_no VARCHAR(64),           -- L4-07 收款流水号(D06支付渠道)
    quote_version_lock INT NOT NULL,                -- L4-08 锁定报价版本号(对应D02 CPT-I2)
    contract_template_version VARCHAR(16),          -- L4-09 合同模板版本号
    contract_hash_sha256 CHAR(64) NOT NULL,         -- L4-10 合同PDF哈希(防篡改:R2归档)
    contract_pdf_r2_url TEXT NOT NULL,              -- L4-11 合同PDF R2永久归档URL(PDPO保留7年)
    additional_tnc_json JSONB,                      -- L4-12 额外附加条款数组(客户特殊约定)
    c7_insurance_purchased BOOLEAN DEFAULT FALSE,   -- L4-13 客户是否加购C7
    c7_insurance_payment_receipt_no VARCHAR(64),    -- L4-14 C7保费收据号
    cooling_period_end_at TIMESTAMPTZ,              -- L4-15 冷静期结束时间(消费权益保护规定7天)
    PRIMARY KEY (project_id_fk)
);

-- ============== L5 派单 20字段 ==============
CREATE TABLE cpt_collect_l5_dispatch (
    project_id_fk   VARCHAR(32) NOT NULL REFERENCES cpt_projects(project_id) ON DELETE CASCADE,
    dispatch_id_fk  VARCHAR(32) NOT NULL,           -- L5-01 DIS派单ID
    preferred_mode_input VARCHAR(8),                -- L5-02 客户/系统输入偏好
    final_combo_id  VARCHAR(8) NOT NULL CHECK (final_combo_id IN ('C11','C12','C13','C21','C22','C23','C31','C32','C33')),  -- L5-03 最终组合Cxx
    final_dlv_id    VARCHAR(32) NOT NULL,           -- L5-04 DLV物流商ID
    final_wkr_id    VARCHAR(32) NOT NULL,           -- L5-05 WKR师傅ID
    final_composite_score NUMERIC(5,2) NOT NULL,    -- L5-06 综合分
    matrix_9_rows_json JSONB NOT NULL,              -- L5-07 9组合完整矩阵(存档用)
    assignments_3_json JSONB NOT NULL,              -- L5-08 3候选详情
    reject_chain_json JSONB,                        -- L5-09 拒单链记录([模式1: who, code, reason, ts]→[模式2]→[模式3])
    retry_count     INT NOT NULL DEFAULT 0,         -- L5-10 重试次数
    escalated_to_ol BOOLEAN DEFAULT FALSE,          -- L5-11 是否升级OL接管
    ol_adjusted     BOOLEAN DEFAULT FALSE,          -- L5-12 是否OL人工调整
    ol_adjust_reason TEXT,                          -- L5-13 I4原因≥20字
    c7_level_active CHAR(2),                        -- L5-14 当前激活C7档位
    c7_insurance_premium NUMERIC(12,2),             -- L5-15 C7保费
    c7_insurance_documentation_json JSONB,          -- L5-16 C7保单明细
    pickup_window_start_at TIMESTAMPTZ,             -- L5-17 约定取货时间窗开始
    installation_window_at TIMESTAMPTZ,             -- L5-18 约定安装时间
    lgp_accepted_at TIMESTAMPTZ,                    -- L5-19 LGP确认接单时间
    wkr_accepted_at TIMESTAMPTZ,                    -- L5-20 WKR师傅确认接单时间
    PRIMARY KEY (project_id_fk)
);

-- ============== L6 安装施工 30字段(核心,CPT-D V2.3 §L6 作业规范子流程6节点)==============
CREATE TABLE cpt_collect_l6_install (
    project_id_fk   VARCHAR(32) NOT NULL REFERENCES cpt_projects(project_id) ON DELETE CASCADE,
    -- 三重校验I4 5字段
    checkin_gps_lat NUMERIC(10,7),                  -- L6-01 打卡时师傅实际GPS纬度
    checkin_gps_lng NUMERIC(10,7),                  -- L6-02 经度
    checkin_gps_distance_meters INT,                -- L6-03 距离BPL围栏中心距离米
    checkin_time_at TIMESTAMPTZ NOT NULL,           -- L6-04 打卡时间
    checkin_passed  BOOLEAN NOT NULL,               -- L6-05 是否三重校验通过(=true才可进入施工)
    -- 作业规范6节点 × 完成与否 + 照片URL ×3 = 18字段
    step1_shoe_covers_passed BOOLEAN,               -- L6-06 节点1:穿鞋套
    step1_shoe_covers_photos_json JSONB,            -- L6-07 节点1照片
    step2_floor_protect_passed BOOLEAN,             -- L6-08 节点2:地面保护/地毯
    step2_floor_protect_photos_json JSONB,          -- L6-09 节点2照片
    step3_old_removed_passed BOOLEAN,               -- L6-10 节点3:拆旧清走(如果需要)
    step3_old_removed_photos_json JSONB,            -- L6-11 节点3照片
    step4_installation_passed BOOLEAN,              -- L6-12 节点4:安装主体
    step4_installation_photos_json JSONB,           -- L6-13 节点4照片
    step5_cleaning_passed BOOLEAN,                  -- L6-14 节点5:场地清理
    step5_cleaning_photos_json JSONB,               -- L6-15 节点5照片
    step6_trash_removed_passed BOOLEAN,             -- L6-16 节点6:垃圾带走(客户确认)
    step6_trash_removed_photos_json JSONB,          -- L6-17 节点6照片
    -- 实际数据 7字段(校准反哺MDS通道一/三)
    actual_hours NUMERIC(4,1),                      -- L6-18 实际总工时(小时)
    actual_material_fees NUMERIC(12,2),             -- L6-19 辅材费实际(对比报价辅材是否偏差>10%)
    actual_complexity_coef NUMERIC(3,2),            -- L6-20 师傅自评复杂度系数(上报校准)
    difficulty_report_json JSONB,                   -- L6-21 遇到的异常:[电梯坏(扛10楼)、管理处刁难、尺寸不合需返工等]
    rework_count    INT DEFAULT 0,                  -- L6-22 返工次数
    installation_complete_photos_json JSONB,        -- L6-23 完工合照数组
    customer_signature_url TEXT NOT NULL,           -- L6-24 客户现场确认电子签名URL
    -- 收尾
    signed_off_name VARCHAR(32),                    -- L6-25 现场签字人姓名
    signed_off_at TIMESTAMPTZ,                      -- L6-26 签字时间
    completed_at    TIMESTAMPTZ,                    -- L6-27 L6完工时间
    wkr_self_evaluation_score NUMERIC(2,0) CHECK (wkr_self_evaluation_score BETWEEN 0 AND 10),  -- L6-28 师傅自评满意度0-10
    management_issue_report_cn TEXT,                -- L6-29 物业/管理问题需OL协助
    next_visit_needed BOOLEAN DEFAULT FALSE,        -- L6-30 是否需要二次上门(补件/调件)
    PRIMARY KEY (project_id_fk)
);

-- ============== L7 验收 17字段 ==============
CREATE TABLE cpt_collect_l7_acceptance (
    project_id_fk   VARCHAR(32) NOT NULL REFERENCES cpt_projects(project_id) ON DELETE CASCADE,
    checklist_item_stability BOOLEAN,               -- L7-01 稳定性(不晃/无异响)
    checklist_item_appearance BOOLEAN,              -- L7-02 外观完整无划痕
    checklist_item_functionality BOOLEAN,           -- L7-03 功能/使用正常
    checklist_item_cleanliness BOOLEAN,             -- L7-04 清洁无污渍
    checklist_item_location BOOLEAN,                -- L7-05 位置按客户要求
    checklist_item_parts_completeness BOOLEAN,      -- L7-06 配件齐全
    checklist_item_documents BOOLEAN,               -- L7-07 说明书/保修卡完整
    checklist_item_warranty_explained BOOLEAN,      -- L7-08 质保年限/范围解释清楚
    checklist_item_contact_ol_card BOOLEAN,         -- L7-09 OL客服名片/二维码交付
    checklist_item_customer_demo_done BOOLEAN,      -- L7-10 演示给客户会用
    checklist_item_trash_verified_removed BOOLEAN,  -- L7-11 客户已确认垃圾清走
    checklist_item_payment_receipt_provided BOOLEAN,-- L7-12 付款收据已给
    checklist_item_satisfaction_overall INT CHECK (checklist_item_satisfaction_overall BETWEEN 1 AND 5),  -- L7-13 总体满意度1-5星
    acceptance_result VARCHAR(16) NOT NULL CHECK (acceptance_result IN ('PASSED_FIRST','PASSED_AFTER_REWORK','PARTIAL_COMPLAINT_ESCALATION','FAILED_REFUND')),  -- L7-14
    corrective_actions_json JSONB,                  -- L7-15 整改行动清单
    corrective_work_count INT DEFAULT 0,            -- L7-16 返工次数
    customer_final_acceptance_signature_url TEXT,   -- L7-17 电子签名URL / 若L7升级则写投诉工单ID
    PRIMARY KEY (project_id_fk)
);

-- ============== L8 回访 31字段(NPS + 口碑闭环)==============
CREATE TABLE cpt_collect_l8_nps (
    project_id_fk   VARCHAR(32) NOT NULL REFERENCES cpt_projects(project_id) ON DELETE CASCADE,
    nps_score_0_10  INT NOT NULL CHECK (nps_score_0_10 BETWEEN 0 AND 10),  -- L8-01 NPS 0-10
    nps_category    VARCHAR(16) NOT NULL CHECK (nps_category IN ('DETRACTOR','PASSIVE','PROMOTER')),  -- L8-02 自动:0-6/7-8/9-10
    nps_comments_cn VARCHAR(500),                  -- L8-03 原因简述
    service_overall_rating_1_5 INT CHECK (service_overall_rating_1_5 BETWEEN 1 AND 5),  -- L8-04
    worker_attitude_rating_1_5 INT,                -- L8-05
    worker_skill_rating_1_5 INT,                   -- L8-06
    communication_clarity_rating_1_5 INT,          -- L8-07
    cleanliness_rating_1_5 INT,                    -- L8-08
    punctuality_rating_1_5 INT,                    -- L8-09
    value_for_money_rating_1_5 INT,                -- L8-10
    recommend_flag  BOOLEAN NOT NULL,              -- L8-11 是否愿意推荐(生成REF码)
    referral_code   VARCHAR(20),                   -- L8-12 REF推荐码
    referral_cpl_id_fk_generated VARCHAR(32),      -- L8-13 推荐人CPL(如果S04场景)
    customer_photo_sharing_permission BOOLEAN,     -- L8-14 客户是否授权照片公开使用(REP2入口碑案例前置条件)
    case_authorized_for_marketing BOOLEAN DEFAULT FALSE,  -- L8-15 营销授权
    story_title_cn  VARCHAR(64),                   -- L8-16 口碑标题(后续BO润色)
    good_points_top3_json JSONB,                   -- L8-17 TOP3好评点(数组)
    improvement_points_json JSONB,                 -- L8-18 改进建议(数组)
    would_use_again_next_12m BOOLEAN,              -- L8-19 未来12个月再用我们?
    wkr_name_mention_in_comments BOOLEAN,          -- L8-20 评论中特别点名某位师傅(个人品牌)
    friend_referral_intent_count INT DEFAULT 0,    -- L8-21 打算推荐给几位朋友(0-10人)
    social_share_intent_ig_fb BOOLEAN,             -- L8-22 是否愿意IG/FB分享
    completed_survey_duration_sec INT,             -- L8-23 填写NPS问卷耗时(秒,分析用户是否认真)
    survey_device_type VARCHAR(8),                 -- L8-24 手机/电脑/平板
    follow_up_call_made BOOLEAN DEFAULT FALSE,     -- L8-25 OL是否电话回访(贬损者要求强制回访)
    follow_up_notes_cn TEXT,                       -- L8-26 回访记录
    complaint_ticket_resolved_flag BOOLEAN,        -- L8-27 若L7有投诉,是否已解决
    complaint_resolution_satisfaction_1_5 INT CHECK (complaint_resolution_satisfaction_1_5 BETWEEN 1 AND 5),  -- L8-28 客诉解决满意度
    cpl_new_tag_suggestions_json JSONB,            -- L8-29 建议新增CPL标签(入TSMM待审核)
    wkr_new_skill_suggestion_json JSONB,           -- L8-30 建议WPL新技能标签
    next_service_follow_up_date DATE,              -- L8-31 建议下一次服务提醒日期(清洁/重装/保修到期)
    PRIMARY KEY (project_id_fk)
);

-- ============== 统一视图:183字段完成度率自动计算(给OPR OL看板I6用) ==============
CREATE OR REPLACE VIEW cpt_183_completion_vw AS
WITH l_counts AS (
    SELECT
        p.project_id,
        p.stage,
        (SELECT count(*) - 1 FROM jsonb_each_text(to_jsonb(l1))) FILTER (WHERE l1.project_id_fk IS NOT NULL) AS l1_fields_not_null,
        18 AS l1_total,
        (SELECT count(*) - 1 FROM jsonb_each_text(to_jsonb(l2))) FILTER (WHERE l2.project_id_fk IS NOT NULL) AS l2_fields_not_null,
        24 AS l2_total,
        (SELECT count(*) - 1 FROM jsonb_each_text(to_jsonb(l3))) FILTER (WHERE l3.project_id_fk IS NOT NULL) AS l3_fields_not_null,
        28 AS l3_total,
        (SELECT count(*) - 1 FROM jsonb_each_text(to_jsonb(l4))) FILTER (WHERE l4.project_id_fk IS NOT NULL) AS l4_fields_not_null,
        15 AS l4_total,
        (SELECT count(*) - 1 FROM jsonb_each_text(to_jsonb(l5))) FILTER (WHERE l5.project_id_fk IS NOT NULL) AS l5_fields_not_null,
        20 AS l5_total,
        (SELECT count(*) - 1 FROM jsonb_each_text(to_jsonb(l6))) FILTER (WHERE l6.project_id_fk IS NOT NULL) AS l6_fields_not_null,
        30 AS l6_total,
        (SELECT count(*) - 1 FROM jsonb_each_text(to_jsonb(l7))) FILTER (WHERE l7.project_id_fk IS NOT NULL) AS l7_fields_not_null,
        17 AS l7_total,
        (SELECT count(*) - 1 FROM jsonb_each_text(to_jsonb(l8))) FILTER (WHERE l8.project_id_fk IS NOT NULL) AS l8_fields_not_null,
        31 AS l8_total
    FROM cpt_projects p
    LEFT JOIN cpt_collect_l1_lead l1 ON p.project_id = l1.project_id_fk
    LEFT JOIN cpt_collect_l2_requirement l2 ON p.project_id = l2.project_id_fk
    LEFT JOIN cpt_collect_l3_quote l3 ON p.project_id = l3.project_id_fk
    LEFT JOIN cpt_collect_l4_contract l4 ON p.project_id = l4.project_id_fk
    LEFT JOIN cpt_collect_l5_dispatch l5 ON p.project_id = l5.project_id_fk
    LEFT JOIN cpt_collect_l6_install l6 ON p.project_id = l6.project_id_fk
    LEFT JOIN cpt_collect_l7_acceptance l7 ON p.project_id = l7.project_id_fk
    LEFT JOIN cpt_collect_l8_nps l8 ON p.project_id = l8.project_id_fk
)
SELECT
    project_id, stage,
    ROUND(((COALESCE(l1_fields_not_null,0)+COALESCE(l2_fields_not_null,0)+COALESCE(l3_fields_not_null,0)+
            COALESCE(l4_fields_not_null,0)+COALESCE(l5_fields_not_null,0)+COALESCE(l6_fields_not_null,0)+
            COALESCE(l7_fields_not_null,0)+COALESCE(l8_fields_not_null,0))::NUMERIC / 183.0)*100, 2) AS completion_pct_183,
    l1_fields_not_null, l1_total,
    l2_fields_not_null, l2_total,
    l3_fields_not_null, l3_total,
    l4_fields_not_null, l4_total,
    l5_fields_not_null, l5_total,
    l6_fields_not_null, l6_total,
    l7_fields_not_null, l7_total,
    l8_fields_not_null, l8_total
FROM l_counts;
-- 完成度查询:SELECT stage, completion_pct_183 FROM cpt_183_completion_vw WHERE project_id='CPT-XXX'; → 直接返回CPT-I6校验%

第三篇 · D03 PostGIS楼宇特征库技术实现详细(BPL)

3.1 PostGIS 3.4扩展启用 & 基础GIS表初始化

-- 初始化(仅首次执行,Alembic 0003/0008合并执行)
CREATE EXTENSION IF NOT EXISTS postgis WITH SCHEMA public;
CREATE EXTENSION IF NOT EXISTS postgis_topology;
-- 检查版本:SELECT PostGIS_Version(); 期望 "3.4 USE_GEOS=1 USE_PROJ=1 USE_STATS=1"(D10 §3.2对齐)

-- ============== 为BPL楼宇表增加geom字段(D10 §3.2核心片段,D03补充完整索引+策略)==============
-- 已在D10 Alembic 0003执行 SELECT AddGeometryColumn → 此处补充D03专用的索引+Polygon围栏 + RLS
ALTER TABLE mds_bpl_building ADD COLUMN IF NOT EXISTS geom_point GEOMETRY(Point, 4326);  -- WGS84经纬度
ALTER TABLE mds_bpl_building ADD COLUMN IF NOT EXISTS geom_fence_50m GEOMETRY(Polygon, 4326); -- 50米自动围栏(ST_Buffer自动生成)
ALTER TABLE mds_bpl_building ADD COLUMN IF NOT EXISTS geom_admin_boundary GEOMETRY(Polygon, 4326); -- 楼宇建筑实际多边形轮廓(人工/OSM导入)
CREATE INDEX IF NOT EXISTS idx_mds_bpl_geom_point_gist ON mds_bpl_building USING GIST (geom_point);
CREATE INDEX IF NOT EXISTS idx_mds_bpl_fence_gist   ON mds_bpl_building USING GIST (geom_fence_50m);
CREATE INDEX IF NOT EXISTS idx_mds_bpl_admin_gist   ON mds_bpl_building USING GIST (geom_admin_boundary);

-- 自动围栏触发器:写入/更新geom_point时自动生成50m缓冲圆(避免手动维护)
CREATE OR REPLACE FUNCTION bpl_auto_generate_50m_fence() RETURNS TRIGGER AS $$
BEGIN
    -- ST_Buffer(geography, 50):用geography米做buffer(而不是geometry度),确保精准50米
    NEW.geom_fence_50m := ST_Transform(
        ST_Buffer(ST_Transform(NEW.geom_point, 3857), 50),  -- Web墨卡托投影做50米buffer
        4326  -- 转回WGS84
    )::Geometry(Polygon, 4326);
    RETURN NEW;
END; $$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_bpl_gen_fence ON mds_bpl_building;
CREATE TRIGGER trg_bpl_gen_fence
BEFORE INSERT OR UPDATE OF geom_point ON mds_bpl_building
FOR EACH ROW EXECUTE FUNCTION bpl_auto_generate_50m_fence();

3.2 L6打卡ST_DWithin精确校验(D04 CPT-I4落地DB层,杜绝RN侧伪造GPS)

D08 §1.3 RN侧Haversine只是本地快速判断给用户UI反馈;最终必须通过后端PG ST_DWithin精确校验才算打卡成功。

-- D08 executeL6ThreeFactorCheckin() 成功后 → 后端 CPT8 /l6-worker-checkin 接口内部SQL:
-- 返回 TRUE/FALSE 表示I4 DB层围栏校验是否通过
CREATE OR REPLACE FUNCTION cpt_verify_l6_gps_fence_db_level(
    project_id VARCHAR(32),
    input_geom_point GEOMETRY(Point, 4326)
) RETURNS TABLE (
    bpl_id_fk_out VARCHAR(32),
    building_name_cn VARCHAR(128),
    distance_to_center_meters NUMERIC(10,2),
    within_50m_fence BOOLEAN,
    within_admin_polygon BOOLEAN
) AS $$
BEGIN
    RETURN QUERY
    SELECT
        p.bpl_id_fk,
        b.building_cn_name,
        ST_DistanceSphere(input_geom_point::geography, b.geom_point::geography)::NUMERIC(10,2) AS dist_m,
        ST_DWithin(
            input_geom_point::geography,
            b.geom_point::geography,
            50  -- 强制50米,和D08 RN代码写死50m保持一致
        ) AS within_50m_fence,
        ST_Intersects(input_geom_point, b.geom_admin_boundary) AS within_admin_polygon
    FROM cpt_projects p
    JOIN mds_bpl_building b ON p.bpl_id_fk = b.bpl_id
    WHERE p.project_id = project_id
    LIMIT 1;
END; $$ LANGUAGE plpgsql VOLATILE SECURITY DEFINER;
-- 注:SECURITY DEFINER 确保即使RLS下也能读取mds_bpl_building全表(BPL楼宇地址数据不设强RLS)

3.3 DIS候选师傅5km半径地理查询(D04 Step1 WKR候选拉取Geo过滤)

-- MDS DIS2 /supply/dis/wkr-candidates 核心内部SQL:按取货点GPS 5km半径内 + SCL技能匹配 + REGISTER_TYPE三分类过滤
-- 性能依赖:idx_mds_bpl_geom_point_gist / idx_wpl_covering_gist
SELECT
    w.wpl_id,
    w.name_cn_display,
    w.level,
    w.level_score,
    w.skills_scl,
    w.coverage_districts,
    w.is_online,
    w.last_gps_lat, w.last_gps_lng,
    ST_DistanceSphere(
        ST_SetSRID(ST_MakePoint(pickup_gps_input_lng, pickup_gps_input_lat), 4326)::geography,
        ST_SetSRID(ST_MakePoint(w.last_gps_lng, w.last_gps_lat), 4326)::geography
    ) / 1000 AS distance_from_pickup_km  -- km
FROM mds_wpl_worker w
WHERE TRUE
  AND w.register_type = COALESCE(register_type_in_filter, w.register_type)
  AND w.is_online = TRUE
  AND w.skills_scl @> ARRAY[scl_code_in_filter]::VARCHAR[]  -- 技能匹配
  AND w.suspended_until IS NULL OR w.suspended_until < NOW()  -- 没被DIS I3挂起
  AND ST_DWithin(
        ST_SetSRID(ST_MakePoint(pickup_gps_input_lng, pickup_gps_input_lat), 4326)::geography,
        ST_SetSRID(ST_MakePoint(w.last_gps_lng, w.last_gps_lat), 4326)::geography,
        5000  -- 5km半径
  )
ORDER BY distance_from_pickup_km ASC
LIMIT limit_in_filter;

3.4 L2需求阶段按地址反查楼宇PostGIS(CPT3 CPT接口D04 §三对齐)

-- CPT3 /supply/cpt/bpl-by-addr 核心SQL:按输入地址文本或模糊匹配楼宇名 → Geo找最近3个BPL楼宇推荐
WITH point AS (
    SELECT ST_SetSRID(ST_MakePoint(input_gps_lng, input_gps_lat), 4326)::geography AS search_center
)
SELECT
    b.bpl_id, b.building_cn_name, b.district, b.building_type_code,
    ST_X(b.geom_point) AS lng, ST_Y(b.geom_point) AS lat,
    ST_DistanceSphere(b.geom_point::geography, p.search_center) AS dist_m,
    -- 相似度:楼宇名模糊匹配得分 (pg_trgm扩展配合GIN索引)
    similarity(b.building_cn_name, input_addr_text) AS name_similarity_score
FROM mds_bpl_building b, point p
WHERE ST_DWithin(b.geom_point::geography, p.search_center, 500)  -- 500米半径找
ORDER BY (name_similarity_score DESC, dist_m ASC)
LIMIT 3;
-- 如果0匹配 → CPT2自动创建BPL DRAFT态楼宇待SCM-MDS OL人工核实(T+1纳入正式BPL库)

3.5 D03 PostGIS性能NFR(P95<100ms)保障

场景 P95目标 优化手段
L6打卡DB级ST_DWithin 50m <20ms GIST索引+ST_DWithin geography 50m走索引;单表百万行下<10ms
DIS师傅5km半径Top30候选 <80ms GIST+技能数组GIN索引复合;online_status+coverage_districts部分索引
CPT3地址反查Top3 <50ms GIST+pg_trgm GIN相似度+limit3
OPS B2B LGP实时轨迹 <150ms 轨迹点落TimescaleDB连续聚合(按LGP_ID/小时分桶)

修订记录

版本 日期 修订人 修订内容
V1.0 2026-08-22 DT 首版D08/D09/D03三合一:①D08(TND-WKR):RN Expo SDK51关键依赖+WKR 10页路由对齐表+L6三重校验GPS/time/photo TypeScript Hook代码+SQLite离线24h队列+挂起处罚Overlay全屏组件;②D09(TND-CPT):8阶段×183字段分解校验表(18+24+28+15+20+30+17+31=183✅)+ PostgreSQL Alembic 0007纯新建8张表DDL(L1~L8各一)+ cpt_183_completion_vw视图(CPT-I6自动%计算,OPR看板直接SELECT);③D03(TND-BPL):PostGIS 3.4启用扩展+geom_point/fence/admin三几何列+GIST索引+自动50m围栏触发器plpgsql+L6打卡ST_DWithin精确校验函数+5km DIS师傅地理查询SQL+地址反查pg_trgm相似度+NFR性能目标表

本文件 DOC-D08/D09/D03 V1.0 三合一:RN Expo WKR端10页100%原型UI对应+GPS三重校验+离线24hSQLite队列+拒单挂起全屏UI;CPT-F 183字段8阶段9表Alembic DDL+I6完成度视图;BPL PostGIS 3.4围栏50m+5km半径查询+地址反查三重能力。D08/D09/D03分别为WKR端、CPT数据主线、BPL楼宇主数据的唯一编码级技术依据。