师傅端 / 客户跟踪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楼宇主数据的唯一编码级技术依据。