跳转至

订单与派单系统技术设计(TND-DIS)

文档编号:DOC-D04 / 简称:TND-DIS 版本:V1.0 创建日期:2026-08-22 维护人:TL / DT(听写编写,听码WDE消费;听云OCM评审§七SAGA补偿可靠性+§六C7保险资金清算审计) 决策审批人:TL + SPO 关联文档D01 TND-ARC ADR-018(SAGA补偿Choreography)/ §五NATS DIS 5事件(#12-16)、CPT-D V2.5(8阶段生命周期+跟踪池+异常处理)、DIS-D V1.1(派单决策引擎+M1/M2/M3 × 安装三模式9组合 + C7赔付策略 + OL人工调整)、D02 TND-QSVE §四DME运输模型写回、D10 TND-MDS WPL/DLV/C7供给、D09 TND-CPT(183字段完整模型)


一、定位与服务划分(CPT跟踪 + DIS派单 = 2服务聚合在TND-D04)

为什么D04合一? DIS派单与CPT跟踪强耦合:CPT L5签约→NATS触发DIS派单;DIS派单结果写回CPT L5 assignment_id;DIS异常自动跳CPT异常池。代码层按模块边界分离,但技术设计在同一份文档中统一描述跨服务交互。

子服务 核心职责 聚合根 主要交互方
CPT 客户项目跟踪(数据主线核心) 8阶段生命周期状态机、跟踪池8类、183字段7类采集物采集、L7客诉L8 NPS CustomerProject(project_id) QSV(L3报价绑定)/ MDS(L2主体引用)/ DIS(L5派单触发+结果回写)/ OPS(L1线索入/L8 NPS出)
DIS 派单决策引擎(决策核心) 9组合(运输3×安装3)候选池排序、OL人工调整、C7保险4档触发、拒单降级重试 DispatchOrder(dispatch_id) + DispatchAssignment(3候选) MDS(DLV档案/WPL技能/C7历史)/ CPT(L5触发/L7客诉SAGA回滚)/ OPS(C7预警出)/ LGP门户(接单API)

二、BAS V1.0 DDD 8要素落地(双服务)

2.1 代码边界

backend/
  cpt/  # ========== CPT客户跟踪模块 ==========
    interfaces/api/cpt_router.py       # 40路由
    application/services/
      ProjectLifecycleAppService.py    # 8阶段编排(状态机驱动)
      TrackingPoolAppService.py        # 8跟踪池调度
      ExceptionL7AppService.py         # L7客诉升级 + SAGA补偿触发
    domain/
      customer_project.py              # 聚合根 CustomerProject + 8阶段状态机
      tracking_pool.py                 # 跟踪池聚合根(8类池)
      value_objects/
        stage_status.py                # L1-L8 枚举 + 流转规则
        collection_items_183.py        # 7类183采集字段VO(D09 §二1:1)
        nps_feedback.py                # L8 9档NPS + REF推荐码
      ports.py                         # ProjectRepository / PoolRepository等
      events.py                        # cpt.* 7事件(D01五#3-9)
    infrastructure/
      db/models.py                     # cpt_projects主表 / 183字段采集表 / 状态历史表
      fsm/statemachine_cpt.py          # 状态机实现:transitions库(Python)或自研
      nats/*                           # 发布L1~L8事件 + 订阅DIS派单事件

  dis/  # ========== DIS派单决策模块 ==========
    interfaces/api/dis_router.py       # 35路由:dispatch调度台API / LGP门户接单API / OL人工调整API
    application/services/
      DispatchMatchingAppService.py    # 派单编排:L5事件→查候选→加权排序→出3推荐
      InsuranceC7AppService.py         # C7保险4档赔付计算 + 记录写回
      OLManualAdjustAppService.py      # OL人工改派:写入adjusted_reason必填 + 审计
    domain/
      dispatch_order.py                # 聚合根 DispatchOrder(5不变量)
      dispatch_assignment.py           # 实体:每单3候选(M1/M2/M3)
      value_objects/
        composite_score.py             # 综合分VO:运输分×0.55 + 安装分×0.45(0~100)
        nine_comb_matrix.py            # 3运输×3安装=9组合矩阵
        c7_payout_tiers.py             # C1/C3/C5/C7 4档阈值
      services/
        dispatch_matching_engine.py    # 9组合匹配器(领域服务)
        c7_insurance_policy.py         # C7保险策略(领域服务)
      ports.py                         # DispatchOrderRepository等
      events.py                        # dis.* 5事件(D01五#12-16)
    infrastructure/
      db/models.py                     # dis_orders / dis_assignments / dis_ins_claims 三主表
      rules/dispatch_rules.json        # JSON-Rules(同D02 §三策略:<200条用JSON-Rules → Drools演进)
      nats/*
        dispatch_req_subscriber.py     # 订阅cpt.dispatch.requested(D01五#5)→ 触发派单计算
        event_publisher.py             # 发布dispatch.created/adjusted/rejected/warning/completed

三、CPT:CustomerProject 8阶段状态机(核心数据主线)

对齐CPT-D §4.1 L1-L8生命周期。状态机实现选型:Python transitions 库(轻量,单体优先P3原则;成熟期若拆服务,事件驱动SAGA即可)。

3.1 状态流转图(Mermaid stateDiagram-v2)

stateDiagram-v2
    direction LR
    [*] --> L1_LEAD: NATS ops.lead.generated(OPS获客线索入池)
    L1_LEAD --> L2_REQ_CONFIRMED: OL联系客户+L2楼宇核实(BPL反哺MDS)
    L1_LEAD --> POOL_DROPPED: 3次未接通→流失池
    L2_REQ_CONFIRMED --> L3_QUOTED: CPT调用QSV→报价version绑定project
    L3_QUOTED --> L3_QUOTE_REVISED: 客户改参数→QSV新版本(version++)
    L3_QUOTED --> L4_CONTRACTED: 客户签字确认(电子签章→R2归档)+ quote_version_lock不可变
    L4_CONTRACTED --> L5_DISPATCHING: CPT发布cpt.dispatch.requested(NATS D01五#5)→ DIS订阅
    L5_DISPATCHING --> L5_DISPATCHED: DIS发布dis.dispatch.created(D01五#12)→ assignment_id写回CPT
    L5_DISPATCHING --> L5_DISPATCH_RETRY: dis.dispatch.rejected(#14)→ 模式降级,重试max=3
    L5_DISPATCH_RETRY --> POOL_EXCEPTION: 三模式全拒绝→异常池,OL人工介入
    L5_DISPATCHED --> L6_INSTALLATION: WKR师傅L6上门打卡(GPS+时间+照片三重校验,见D08 §三)
    L6_INSTALLATION --> L6_INSTALL_COMPLETED: 师傅上传完工照片+客户签字(CPT发布cpt.install.completed #6)
    L6_INSTALL_COMPLETED --> L7_ACCEPTANCE: 客户验收
    L7_ACCEPTANCE --> L7_COMPLAINT_ESCALATION: 客户发起L7客诉→SAGA补偿(§七)
    L7_ACCEPTANCE --> L8_NPS: 验收通过→L8回访NPS采集
    L7_COMPLAINT_ESCALATION --> L8_NPS: 客诉闭环后进入NPS
    L8_NPS --> COMPLETED: NPS评分+REF推荐码→发布cpt.nps.feedback #8 + cpt.actual.calibration #9(T+1批量)
    COMPLETED --> [*]
    POOL_DROPPED --> POOL_REACTIVATE: 30天后OL重激活(二次营销OPS接管)
    POOL_EXCEPTION --> L1_LEAD: 人工解决(如换师傅/加价)后重新入L1

    state L5_DISPATCH_RETRY {
        note right of L5_DISPATCH_RETRY
          重试策略:优先模式→降级→再降级
          例:首选M3→M2→M1→全模式不可用
          每次reject:WKR师傅M2连续2拒=-25分+30min挂起
        end note
    }

3.2 状态机不变量(强制代码校验)

# backend/cpt/domain/customer_project.py(聚合根,不变量对应状态机)
class CustomerProject:
    # ===== 不变量(6条)=====
    def I1_stage_order(self, from_stage, to_stage):
        """I1: 阶段跳级禁止,仅允许 L(n)→L(n±1) 或进入异常池"""
        legal_jump = {
            ("L1","L2"), ("L2","L3"), ("L3","L4"), ("L4","L5"),
            ("L5","L6"), ("L6","L7"), ("L7","L8"), ("L8","COMPLETED"),
            ("L*","POOL_*"),  # 异常跳池允许
        }
        if (from_stage, to_stage) not in legal_jump:
            raise ValueError(f"CPT-I1 违反: 非法跳级 {from_stage}{to_stage}")

    def I2_quote_locked_after_L4(self, stage, quote_version_changed: bool):
        """I2: L4签约后quote_version不可变(防止坐地起价)"""
        if stage >= "L4" and quote_version_changed:
            raise ValueError("CPT-I2 违反: L4签约后报价不可变更,需走L7客诉+补偿流程")

    def I3_assignment_only_L5(self, stage, operation="assign"):
        """I3: 派单操作仅允许在L5阶段"""
        if stage != "L5_DISPATCHING" and operation == "assign":
            raise ValueError("CPT-I3 违反: 非L5阶段不可派单")

    def I4_gps_check_required_L6(self, wkr_gps, bpl_geom):
        """I4: L6师傅打卡必须满足GPS在楼宇50m围栏内(PostGIS ST_DWithin)"""
        from geoalchemy2.functions import ST_DWithin
        if not ST_DWithin(wkr_gps, bpl_geom, 50):
            raise ValueError("CPT-I4 违反: L6 GPS未在楼宇50m围栏内,拒绝打卡")

    def I5_refund_requires_l7(self, refund_requested):
        """I5: 退款/扣款逆向SAGA必须先有L7客诉工单记录"""
        if refund_requested and not self.l7_complaint_id:
            raise ValueError("CPT-I5 违反: 无L7客诉工单不可发起资金SAGA")

    def I6_183_required_percentage(self, stage, collection_completion_pct):
        """I6: 各阶段采集完成率最低阈值:L3≥70%、L6≥95%、L8=100%"""
        threshold = {"L3": 0.70, "L6": 0.95, "L8": 1.00}.get(stage)
        if threshold and collection_completion_pct < threshold:
            raise ValueError(f"CPT-I6 违反: {stage}采集完成度{collection_completion_pct}<{threshold}")

四、DIS:派单决策引擎9组合 + 综合分加权(DIS-D V1.1 落地)

4.1 派单总体流程(对齐DIS-D §三+D02 §四DME)

flowchart TD
    NATS_TRIGGER["订阅NATS cpt.dispatch.requested(D01五#5)<br/>载荷:project_id/pickup_addr/delivery_addr/preferred_mode/c7_level/weight_kg/volume_m3/scl_code"]
    STEP1["Step1: MDS供给查询拉候选<br/>GET /api/mds/supply/dis/dlv-candidates(D10 §四.12接口×2:DLV物流商+WKR师傅)<br/>Redis缓存60s"]
    STEP2["Step2: 9组合矩阵生成(运输M1/M2/M3 × 安装M1_SELF/M2_FREE/M3_SME = 3×3=9行)<br/>模式过滤:<100kg且港岛=M1自营优先;≥200kg+跨境=强制M3;小众品类≥2=M2强制"]
    STEP3["Step3: 每行候选分计算<br/>运输分 = DME性价比分(0~100)×历史准时率 × 投诉率惩罚<br/>安装分 = 技能匹配分 × 师傅等级系数 × 实时可用 + 距离GPS衰减<br/>综合分 = 运输分×0.55 + 安装分×0.45<br/>C7赔付历史负向加权:DLV赔付率>C1阈值→运输分-15"]
    STEP4["Step4: 过滤非法组合(M3_SME必须绑定PPL企业ID等WPL不变量#2)"]
    STEP5["Step5: 按综合分降序→取每模式Top1→生成3个Assignment候选(不是Top3,保证三模式各一推荐)"]
    STEP6["Step6: 规则引擎二次过滤(如:客户指定高端品牌=必须M1+M1_SELF组合)"]
    FINAL["生成DispatchOrder聚合根 → INSERT 3 assignment行<br/>→ NATS发布dis.dispatch.created(D01五#12)<br/>→ 写回CPT L5阶段assignment_id<br/>→ 推送WKR/LGP门户通知(Expo Push)"]

    NATS_TRIGGER --> STEP1 --> STEP2 --> STEP3 --> STEP4 --> STEP5 --> STEP6 --> FINAL

4.2 9组合矩阵定义

对齐WPL V1.2 §2.4 REGISTER_TYPE三分类 × D02 §四 DME运输三模式。

组合ID 运输模式 安装模式 REGISTER_TYPE匹配 典型客单 综合分基础权重(冷启动)
C11 M1自营车 M1_SELF自营师傅 M1(必须M1绑定) 高价值客户/指定(优先级×1.15) 基础=90(质量优先)
C12 M1自营车 M2_FREE个体师傅 M2(自雇注册) 标准客单弹性供给 基础=70
C13 M1自营车 M3_SME签约小B M3+PPL企业绑定 批量工程/厂家合作 基础=65(企业成本另付)
C21 M2独立司机 M1_SELF M1 离岛/过海单 基础=60
C22 M2独立司机 M2_FREE M2 高峰期默认首选 基础=75(性价比最优)
C23 M2独立司机 M3_SME M3 中等规模 基础=60
C31 M3专业物流 M1_SELF M1 跨境/厂家批量 基础=80
C32 M3专业物流 M2_FREE M2 跨境+弹性 基础=70
C33 M3专业物流 M3_SME M3 MFR批量/≥C7保险必选 基础=95(合规优先)×强制场景MULT=1.5

4.3 综合分计算公式(伪代码,Python)

# backend/dis/domain/services/dispatch_matching_engine.py
def calc_composite_score(combo_id: str, dlv_cand, wkr_cand, project_ctx) -> CompositeScore:
    # 基础权重(§4.2矩阵)
    base = {
        "C11":90, "C12":70, "C13":65, "C21":60, "C22":75, "C23":60,
        "C31":80, "C32":70, "C33":95,
    }[combo_id]

    # === 运输分(0~100):DME性价比 + 准时率 + 投诉率惩罚 + C7历史扣分 ===
    dme_value = 100 - (dlv_cand.quoted_cost / project_ctx.budget_transport_max) * 80
    dme_value = max(0, min(100, dme_value))  # clamp 0~100
    on_time_coef = dlv_cand.on_time_rate_last_30d  # 0~1.0,如0.95
    complaint_penalty = min(20, dlv_cand.complaint_rate_last_30d * 200)  # 如10%=20分扣
    c7_penalty = min(15, dlv_cand.c7_payout_count_90d * 3)  # 每1次90天赔付扣3分,上限15
    transport_score = dme_value * on_time_coef - complaint_penalty - c7_penalty
    transport_score = max(0, min(100, transport_score))

    # === 安装分(0~100):技能匹配 + 等级系数 + 实时可用 + GPS距离衰减 ===
    skill_match = wkr_cand.skill_match_score_scl(project_ctx.scl_code)  # 0~100 技能矩阵匹配SCL细目
    level_coef = {"NORM":0.9, "AUTH":1.0, "GOLD":1.2}[wkr_cand.level]
    availability_bonus = 30 if wkr_cand.is_online_and_idle_now else 0   # 在线空闲+30分
    distance_penalty = min(20, wkr_cand.distance_from_pickup_km * 2)    # 超10km扣满20
    install_score = skill_match * level_coef + availability_bonus - distance_penalty
    install_score = max(0, min(100, install_score))

    # === 综合分加权(核心公式:运输×0.55 + 安装×0.45)===
    composite_raw = transport_score * 0.55 + install_score * 0.45

    # 场景乘数(如:MFR≥5单 → C33分×1.5;跨境勾选→所有非M3运输分×0.8惩罚)
    scene_multiplier = 1.0
    if project_ctx.is_mfr_batch_order and combo_id == "C33":
        scene_multiplier = 1.5
    if project_ctx.requires_cross_border and combo_id[1] != '3':
        scene_multiplier *= 0.8
    final = round(composite_raw * scene_multiplier, 2)

    return CompositeScore(
        combo_id=combo_id, base_weight=base,
        transport_score=transport_score, install_score=install_score,
        composite_raw=composite_raw, multiplier=scene_multiplier, final=final
    )

4.4 DispatchOrder聚合根5不变量

# backend/dis/domain/dispatch_order.py
class DispatchOrder:
    def I1_three_modes_at_least_one(self, assignments):
        """I1: 至少一候选,且三模式(M1/M2/M3)各有一条(非null)"""
        modes = {a.mode_group for a in assignments}
        if not {"M1","M2","M3"}.issubset(modes):
            raise ValueError("DIS-I1 违反: 三模式候选池缺项")

    def I2_composite_0_100_range(self, score):
        """I2: 综合分严格∈[0,100],禁止负值或>100"""
        if not 0 <= score.final <= 100:
            raise ValueError(f"DIS-I2 违反: 综合分={score.final},必须0~100")

    def I3_reject_2_consecutive_penalty(self, wkr_id, reject_count, current_time):
        """I3: M2师傅2连拒=-25分 + 30分钟挂起(对齐WPL不变量#3)"""
        if reject_count >= 2:  # 连续拒单
            MDS_CALIBRATION_PAYLOAD.publish({
                "event":"wpl_score_penalty", "wkr_id": wkr_id, "penalty": -25
            })
            # SET Redis wkr:{id}:suspended_until = NOW + 30min
            return PenaltyResult(points=-25, suspend_minutes=30)

    def I4_ol_adjust_required_reason(self, adjusted, reason):
        """I4: OL人工调整必须写明原因(≥20字),强制审计"""
        if adjusted and (not reason or len(reason.strip()) < 20):
            raise ValueError("DIS-I4 违反: 人工改派原因≥20字,强制审计留痕")

    def I5_m3_sme_must_bind_ppl(self, wkr_register_type, ppl_enterprise_id):
        """I5: M3_SME安装师傅必须绑定PPL企业ID(对齐WPL V1.2不变量#2)"""
        if wkr_register_type == "M3_SME" and not ppl_enterprise_id:
            raise ValueError("DIS-I5 违反: M3_SME安装模式缺少绑定PPL企业ID,D10 WPL CHECK约束")

五、拒单降级重试(Saga Choreography级本地补偿)

flowchart TD
    ASSIGN1["候选1(默认首选综合分TOP1:如C22 M2×M2)派单→推送LGP/WKR"]
    ASSIGN1 --> R1{"LGP/WKR接受?30s超时=拒"}
    R1 -- "接受" | SUCCESS --> SUCCESS["dis.dispatch.created → CPT L5→L6推进"]
    R1 -- "拒绝/超时" | REJECTED1 --> LOG1["记录拒单原因 → 执行DIS I3处罚(M2连拒=-25+30min)"]
    LOG1 --> NEXT2["降级:候选2(次高分,如C11 M1×M1)"]
    NEXT2 --> R2{"接受?"}
    R2 -- "是" --> SUCCESS
    R2 -- "否/超时" --> LOG2["第二次拒单记录+处罚"]
    LOG2 --> NEXT3["降级:候选3(如C33 M3×M3兜底,成本最高但最可靠)"]
    NEXT3 --> R3{"接受?"}
    R3 -- "是" --> SUCCESS
    R3 -- "否/超时" --> EXCEPTION["全模式不可用→发布dis.exception.no_resource→CPT L5→POOL_EXCEPTION→OL报警→人工调度台dispatch.html处理"]

六、C7保险赔付4档策略(C1/C3/C5/C7,对齐DIS-D §6.4)

与D02 §五.C7加价险联动;C7资金流由听云OCM §生产清算审计。

C7档位 赔付触发条件(损率=损失/报价商品价值) 赔付比例(客户获赔) 免赔额 典型场景
C1(基础档,258HKD客户加购价) 损率 ≥ 5% 实际损失×50% 最高 5,000 HKD 标准家具(≤2万货值)
C3(中档) 损率 ≥ 3% 实际损失×70% 最高 20,000 HKD 家电+部分家具(货值2-10万)
C5(高档) 损率 ≥ 1% 实际损失×90% 最高 50,000 HKD 高档家具、钢琴、易碎品
C7(全额档,跨境必选) 任何损失,含丢件/跨境清关损 实际损失×100% 无上限+跨境HS报关索赔 奢侈品、跨境物流、MFR批量货值>10万

6.1 C7事件流

DIS发布 InsuranceWarning → OPS订阅(D01五#15): - 损率命中阈值 → DIS触发预警事件 - OPS创建保险工单,通知客户/师傅补充证据 - C7赔付完成 → 写D10 mds_dlv_insurance_claims(负向影响DLV派单评分) - 发布dis.insurance.warning主题 → OPS保险理赔页Dashboard展示


七、L7客诉SAGA补偿(ADR-018落地 · 5服务Choreography链式补偿)

7.1 典型场景:客户验收时发现货损+安装错误→发起L7客诉→需要1)退款2)撤销师傅评分3)撤销物流商派单评价4)撤销C7保险预警

Orchestrator?No: Choreography无中心节点(ADR-018原则),每服务订阅正向事件→触发本地补偿→发布对应.compensate反向事件。

flowchart LR
    TRIGGER["触发:客户在CST App L7页面点击【客诉升级】<br/>→ CPT ExceptionL7AppService.validate_I5(refund=True→必须有l7_ticket_id)"]
    CPT_REV["CPT发布<br/>cpt.l7.complaint_opened(正向)<br/>载荷:project_id/refund_requested_amount/reason"]

    CPT_REV --> QSV_SUB["QSV订阅→冻结该quote_version<br/>不可再做重算"]
    QSV_SUB --> QSV_PUB["QSV发布<br/>qsv.quote.frozen"]

    CPT_REV --> WPL_SUB["MDS订阅→撤销此次L8对该师傅的加分<br/>如果是虚高评分"]
    WPL_SUB --> MDS_W_PUB["MDS发布<br/>mds.wpl.score_adjusted.compensate<br/>(反向事件,补偿撤销)"]

    CPT_REV --> DLV_SUB["DIS订阅→撤销此次DLV派单5星好评<br/>DLV综合分回滚(如+1.5→回退)"]
    DLV_SUB --> DIS_D_PUB["DIS发布<br/>dis.dlv_score.rollback.compensate"]

    CPT_REV --> INS_SUB["DIS订阅→若之前已经C7警告则标记为<br/>客诉争议→暂停赔付,等待仲裁"]
    INS_SUB --> DIS_I_PUB["DIS发布 dis.insurance.paused.compensate"]

    QSV_PUB & MDS_W_PUB & DIS_D_PUB & DIS_I_PUB --> ALL_DONE["SAGA Orchestrator(可选)监控所有compensate事件完成后<br/>→ 触发资金退款SAGA(生产由OCM执行,支付宝HK/FPS退)<br/>→ saga_state DB表写CLOSED"]

7.2 SAGA状态表(持久化补偿)

CREATE TABLE dis_saga_state (
    saga_id         VARCHAR(32) PRIMARY KEY,        # SAGA-YYYYMMDD-NNNN
    saga_type       VARCHAR(32) NOT NULL,           # L7_REFUND / DISPATCH_RETRY_ROLLBACK
    trigger_id      VARCHAR(32) NOT NULL,           # project_id / dispatch_id
    steps_total     INT NOT NULL,                    # 5步(QSV+MDS WPL+DIS DLV+DIS C7+资金)
    steps_completed INT NOT NULL DEFAULT 0,
    status          VARCHAR(16) NOT NULL DEFAULT 'RUNNING'
                      CHECK (status IN ('RUNNING','COMPLETED','FAILED_COMPENSATING','DEAD')),
    dead_letter_reason JSONB,                        # 补偿失败时死信原因
    created_at      TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    closed_at       TIMESTAMPTZ
);
CREATE INDEX idx_saga_status ON dis_saga_state(status);

八、PostgreSQL DDL(Alembic 0005,纯新建无ALTER V3.3)

183字段CPT跟踪表完整DDL详见D09 TND-CPT §三;此处仅列DIS派单三主表 + SAGA状态表 + CPT核心主表摘要DDL。

-- ========== DIS三主表 ==========
CREATE TABLE dis_orders (
    dispatch_id       VARCHAR(32) PRIMARY KEY,        -- DIS-YYYYMMDD-NNNN
    project_id_fk     VARCHAR(32) NOT NULL REFERENCES cpt_projects(project_id),
    preferred_mode    VARCHAR(16),                    -- M1/M2/M3(系统建议,客户可改)
    final_combo_id    VARCHAR(8),                     -- 最终选中C11~C33
    final_assignment_id BIGINT,                       -- 最终选中的assignment FK
    final_score       NUMERIC(5,2),
    c7_level          CHAR(2) NOT NULL DEFAULT 'C1' CHECK (c7_level IN ('C1','C3','C5','C7')),
    dispatch_status   VARCHAR(32) NOT NULL DEFAULT 'MATCHING',
    ol_adjusted_by    VARCHAR(32),                    -- OL人工调整人ID
    ol_adjusted_reason TEXT,                          -- I4 ≥20字强制校验
    created_at        TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    completed_at      TIMESTAMPTZ
);
CREATE INDEX idx_dis_project ON dis_orders(project_id_fk);
CREATE INDEX idx_dis_status_created ON dis_orders(dispatch_status, created_at DESC);

CREATE TABLE dis_assignments (
    id                BIGSERIAL PRIMARY KEY,
    dispatch_id_fk    VARCHAR(32) NOT NULL REFERENCES dis_orders(dispatch_id) ON DELETE CASCADE,
    combo_id          VARCHAR(8) NOT NULL CHECK (combo_id ~ '^C[123][123]$'),  -- C11~C33
    dlv_id_fk         VARCHAR(32) NOT NULL REFERENCES mds_dlv_logistics(dlv_id),
    wkr_id_fk         VARCHAR(32) NOT NULL REFERENCES mds_wpl_worker(wpl_id),
    transport_score   NUMERIC(5,2) NOT NULL CHECK (transport_score BETWEEN 0 AND 100),
    install_score     NUMERIC(5,2) NOT NULL CHECK (install_score BETWEEN 0 AND 100),
    composite_final   NUMERIC(5,2) NOT NULL CHECK (composite_final BETWEEN 0 AND 100),  -- I2
    quoted_cost_total NUMERIC(12,2) NOT NULL,
    eta_delivery      TIMESTAMPTZ,
    state             VARCHAR(16) NOT NULL DEFAULT 'PROPOSED'
                          CHECK (state IN ('PROPOSED','ACCEPTED','REJECTED','TIMEOUT','ROLLBACK')),
    reject_code       VARCHAR(8),                     -- R01价格低/R02时间错/R03能力不足/R04其他
    UNIQUE (dispatch_id_fk, combo_id)
);

CREATE TABLE dis_insurance_claims (   -- C7四档(与D10 mds_dlv_insurance_claims 1:1对齐)
    claim_id          VARCHAR(32) PRIMARY KEY,
    dispatch_id_fk    VARCHAR(32) NOT NULL REFERENCES dis_orders(dispatch_id),
    c7_level          CHAR(2) NOT NULL,
    damage_rate_pct   NUMERIC(5,2) NOT NULL,           -- 损率,触发阈值C1≥5/C3≥3/C5≥1/C7任何
    payout_amount     NUMERIC(12,2) NOT NULL,
    status            VARCHAR(16) NOT NULL DEFAULT 'PENDING'
);

-- ========== CPT核心主表摘要(183字段完整见D09)==========
CREATE TABLE cpt_projects (
    project_id        VARCHAR(32) PRIMARY KEY,        -- CPT-YYYYMMDD-NNNN
    stage             VARCHAR(32) NOT NULL DEFAULT 'L1_LEAD',
    quote_id_fk       VARCHAR(32) NULL REFERENCES qsv_quotes(quote_id),
    quote_version_lock INT NULL,                       -- I2 L4后不可变
    cst_id_fk         VARCHAR(32) NOT NULL REFERENCES mds_cpl_customer(cpl_id),
    wkr_id_fk         VARCHAR(32) NULL REFERENCES mds_wpl_worker(wpl_id),
    bpl_id_fk         VARCHAR(32) NULL REFERENCES mds_bpl_building(bpl_id),
    scl_code          VARCHAR(16) NOT NULL,
    ppl_id_fk         VARCHAR(32) NULL REFERENCES mds_ppl_producer(ppl_id),
    l7_complaint_id   VARCHAR(32) NULL,                -- I5 客诉ID
    created_at        TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_cpt_stage_time ON cpt_projects(stage, created_at DESC);

-- SAGA状态表(§七.2)同前...

九、NFR:DIS派单P95<5s + CPT查询P95<500ms

步骤 耗时预算 优化手段
DIS派单P95<5s(D01 §十NFR对齐)
MDS供给DLV/WKR候选拉取 ≤500ms D10 Redis缓存60s;DB加组合索引(register_type, level, coverage)
9组合计算(3×3=9候选×综合分公式) ≤500ms 纯CPU计算,Py3.12;批量分数计算用numpy向量化
LGP/WKR推送+等待30s超时(单次) ~30000ms(太长) 异步推送并行:3候选同时推送,不阻塞HTTP响应;CPT L5状态显示「派单中」;状态用WebSocket/SSE推送OPR调度台
候选1接受 ≤200ms + 用户点击耗时(秒级) LGP门户/App推送高优先级通知(APNs/FCM high priority)
CPT列表P95<500ms
OPR跟踪池分页查 ≤400ms PG索引+Redis缓存OPR常用跟踪池筛选条件结果集10s

十、Dxx关联图

graph TD
    D04["D04 TND-DIS(本文件)"]
    D01["D01(SAGA ADR-018 / NATS 5事件流)"]
    D02["D02 QSVE(L3报价绑定L4锁 / DME三模式成本写回)"]
    D10["D10 MDS(WPL REGISTER_TYPE不变量 / DLV档案+C7历史供给)"]
    D09["D09 TND-CPT(183字段完整模型 / L6三重GPS校验)"]
    D08["D08 TND-WKR(师傅端L6打卡三重校验 + 拒单UI处罚通知)"]
    D05["D05 TND-B2B(LGP门户接单方面 + MFR批量强制C33场景)"]
    D07["D07 TND-SEC(C7资金审计 + SAGA状态表RLS)"]
    API["OpenAPI 75路由(CPT40 + DIS35)"]

    D01 --> D04
    D02 & D10 --> D04
    D04 --> D09 & D08 & D05 & D07 & API

修订记录

版本 日期 修订人 修订内容
V1.0 2026-08-22 DT 首版:①CPT+DIS双服务合一说明与代码边界;②CustomerProject聚合根6不变量代码+8阶段状态机Mermaid图;③DIS派单引擎Step1-6流程图+9组合矩阵定义(C11~C33)+ 综合分加权公式代码(运输×0.55+安装×0.45);④DispatchOrder 5不变量(含M3_SME↔PPL绑定校验 I5);⑤3候选拒单降级重试流;⑥C7四档赔付策略矩阵+事件流;⑦L7客诉SAGA Choreography 5服务补偿图 + saga_state表DDL;⑧Alembic 0005 DDL(DIS三主表+CPT主表摘要);⑨NFR分解(派单P95<5s+CPT<500ms);⑩Dxx关联图

本文件为 D04(TND-DIS V1.0 订单与派单系统技术设计),覆盖CPT 8阶段状态机 + DIS派单9组合加权决策 + C7保险4档 + L7 SAGA补偿;所有跨服务交互通过D01 §五 NATS JetStream 20主题中的14个实现,确保最终一致性;派单P95<5s采用异步并行推送 + OPR调度台WebSocket实时刷新策略。