Files
nexus/Project/fonrey/DATA_MODEL/DATA_MODEL_PROPERTY.md
2026-04-30 20:33:51 +08:00

61 KiB
Raw Blame History

For AI assistants: Read this entire file before writing any code. All decisions here are final. Do not suggest alternatives unless asked.

Fonrey — 房源模块数据模型DATA_MODEL_PROPERTY

所属系统: Fonrey 房产经纪管理系统 版本: v1.0 日期: 2026-04-24 关联模块: apps/property/ 主文档引用DATA_MODEL.md §3.3§3.16 为本文件的概览摘要,开发以本文件为准。
版本v1.0 | 日期2026-04-24


变更历史

日期 变更人 变更内容
2026-04-30 Atlas 补充“变更历史”章节(文档治理)

1 模块说明

房源Property 是 Fonrey 系统的核心领域对象,代表一套二手房源的完整档案。

核心业务规则:

规则 说明
多态交易状态 一套房源可同时处于出售、出租或租售三态(status
流通属性 公盘/私盘/特盘/封盘(attribute),控制可见范围
相关方体系 首录方/号码方/出售方/实买方,通过 staff_id 关联
联系人加密 业主/联系人手机号 AES-256-GCM 加密SHA-256 哈希索引
跟进日志不可删 sensitive_view 类型跟进 is_deletable=FALSE,合规强制
挂牌历史不可删 listing_historiesdeleted_atappend-only
调价记录不可删 price_changesdeleted_atappend-only
完成度异步计算 completeness_score 由 Celery 任务更新,不实时

2 表清单

# 表名 说明 是否可删除
1 properties 房源主表 软删除(deleted_at
2 property_contacts 业主/联系人(手机号加密) 软删除
3 listing_histories 挂牌历史快照 不可删除
4 price_changes 调价记录 不可删除
5 follow_logs 跟进日志6种类型 部分不可删(sensitive_view
6 follow_log_attachments 跟进附件(图片) 随日志联级
7 follow_log_recordings 跟进录音 随日志联级
8 property_keys 钥匙管理 软删除(is_active=FALSE
9 key_attachments 钥匙附件 随钥匙联级
10 commissions 委托管理 状态驱动(status='cancelled'
11 commission_attachments 委托附件 随委托联级
12 field_surveys 实勘管理 软删除
13 survey_photos 实勘照片(按空间分类) 随实勘联级
14 property_photos 房源图片(经纪人管理) 软删除
15 property_attachments 房源附件 直接删除
16 property_marketing 营销信息1:1 随房源联级
17 property_certificates 产证信息1:1 随房源联级
18 property_completeness 维护完成度快照1:1 随房源联级
19 property_tags 标签字典 系统标签不可删
20 property_tag_relations 房源↔标签多对多 随房源/标签联级
21 property_favorites 经纪人收藏房源 直接删除
22 property_protections 保护房设置1:1 随房源联级
23 number_holder_approvals 号码方变更审批 随房源联级

3 枚举值总览

property_type房源类型

说明
residential 住宅
villa 别墅
commercial_residential 商住
shop 商铺
office 写字楼
other 其他

status交易状态

说明
for_sale 出售
for_rent 出租
for_sale_rent 租售
suspended 暂缓
sold_elsewhere 他售
rented_elsewhere 他租
sold 成交
unlisted 未挂牌

attribute流通属性

说明
public 公盘
private 私盘
special 特盘
sealed 封盘

orientation朝向

说明
east
south
west 西
north
southeast 东南
northeast 东北
east_west 东西
south_north 南北
northwest 西北
southwest 西南

decoration装修情况

说明
rough 毛坯
plain 清水
simple 简装
medium 中装
fine 精装
luxury 豪装

grade等级

说明
A A急迫
B B较强
C C一般
D D较弱

follow_log.log_type跟进日志类型

说明 可删除
written 经纪人主动写入跟进
modified 字段变更自动生成
sensitive_op 敏感信息操作跟进
sensitive_view 敏感信息查看(查看号码等)
other 其他(钥匙/新增联系人等)
system 系统日志

4 DDL 定义

4.1 properties房源主表

-- ============================================================
-- 房源主表:系统最核心的表,全部筛选/排序/搜索围绕此表展开
-- 设计重点89,000+ 数据量,复合索引策略,分区预留
-- ============================================================

CREATE TABLE properties (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),  -- 主键(系统生成,业务无关)

    -- ── 基础分类 ──
    property_type   VARCHAR(20) NOT NULL                         -- 房源类型residential=住宅 / villa=别墅 / commercial_residential=商住 / shop=商铺 / office=写字楼 / other=其他(详见 ENUMS.md §property_type
                    CHECK (property_type IN ('residential','villa','commercial_residential',
                                             'shop','office','other')),

    -- ── 交易状态 ──
    status          VARCHAR(20) NOT NULL DEFAULT 'for_sale'      -- 房源交易状态for_sale=出售 / for_rent=出租 / for_sale_rent=租售 / suspended=暂缓 / sold_elsewhere=他售 / rented_elsewhere=他租 / sold=成交 / unlisted=未挂牌(详见 ENUMS.md §status
                    CHECK (status IN ('for_sale','for_rent','for_sale_rent',
                                      'suspended','sold_elsewhere','rented_elsewhere',
                                      'sold','unlisted')),

    -- ── 流通属性 ──
    attribute       VARCHAR(20) NOT NULL DEFAULT 'public'        -- 房源流通属性public=公盘 / private=私盘 / special=特盘 / sealed=封盘;控制可见范围(详见 ENUMS.md §attribute
                    CHECK (attribute IN ('public','private','special','sealed')),
    private_reason  TEXT,                      -- 私盘/封盘必填说明attribute 为 private/sealed 时必填最多200字

    -- ── 位置信息 ──
    complex_id      UUID NOT NULL REFERENCES complexes(id) ON DELETE RESTRICT,  -- 所属楼盘(关联 complexes 表,房源必须挂在楼盘下,禁止级联删除)
    building_id     UUID REFERENCES buildings(id) ON DELETE SET NULL,            -- 所属楼栋(关联 buildings 表;楼栋被删除时置 NULL
    block_no        VARCHAR(30),               -- 栋/幢/弄号(如"3栋"、"A幢";与 unit_no 组合定位具体位置)
    unit_no         VARCHAR(30),               -- 单元号(如"1单元"、"055"
    room_no         VARCHAR(30),               -- 房号/门牌号(如"0301"、"1502"
    floor           SMALLINT NOT NULL,         -- 所在楼层(正整数,不超过 total_floorsCheckConstraint 校验)
    total_floors    SMALLINT NOT NULL,         -- 楼栋总层数(正整数)
    CONSTRAINT chk_floor CHECK (floor > 0 AND floor <= total_floors),  -- 楼层合法性约束:所在楼层必须 > 0 且 ≤ 总楼层

    -- ── 户型 ──
    bedroom_count       SMALLINT NOT NULL DEFAULT 0,    -- 卧室数(室)
    living_room_count   SMALLINT NOT NULL DEFAULT 0,    -- 客厅/餐厅数(厅)
    bathroom_count      SMALLINT NOT NULL DEFAULT 0,    -- 卫生间数(卫)
    kitchen_count       SMALLINT NOT NULL DEFAULT 0,    -- 厨房数(厨)
    balcony_count       SMALLINT NOT NULL DEFAULT 0,    -- 阳台数0=无阳台)

    -- ── 面积 ──
    area            NUMERIC(8,2) NOT NULL,          -- 建筑面积含公摊录入必填
    inner_area      NUMERIC(8,2),                   -- 套内面积不含公摊选填编辑页专属字段

    -- ── 价格 ──
    sale_price          NUMERIC(12,2),              -- 挂牌售价(万元;出售类房源必填,出租类可为 NULL
    sale_bottom_price   NUMERIC(12,2),              -- 售底价(万元;业主心理底价,仅内部可见,不对外展示)
    sale_record_price   NUMERIC(12,2),              -- 备案/核验价(万元;填写后同步至营销库)
    rent_price          NUMERIC(10,2),              -- 挂牌租价(元/月;出租类房源使用)

    -- ── 基础物理属性 ──
    orientation     VARCHAR(10)                                  -- 朝向east=东 / south=南 / west=西 / north=北 / southeast=东南 / northeast=东北 / east_west=东西 / south_north=南北 / northwest=西北 / southwest=西南(详见 ENUMS.md §orientation
                    CHECK (orientation IN ('east','south','west','north',
                                          'southeast','northeast','east_west',
                                          'south_north','northwest','southwest')),
    decoration      VARCHAR(10)                                  -- 装修情况rough=毛坯 / plain=清水 / simple=简装 / medium=中装 / fine=精装 / luxury=豪装(详见 ENUMS.md §decoration
                    CHECK (decoration IN ('rough','plain','simple','medium',
                                          'fine','luxury')),
    has_elevator    BOOLEAN,                                     -- 是否有电梯true=有 / false=无 / NULL=未确认
    built_year      SMALLINT,                                    -- 建成年份(如 2018可空老房源无记录建成年代为空可能影响营销发房

    -- ── 用途 ──
    usage_type      VARCHAR(30),               -- 房屋用途大类(如:住宅 / 商住 / 商业;对应更改用途浮窗第一级下拉)
    usage_subtype   VARCHAR(30),               -- 房屋用途细分小类(如:普通住宅 / 花园洋房;对应更改用途浮窗第二级下拉)

    -- ── 商铺专属 ──
    shop_frontage   NUMERIC(6,2),              -- 开间(米;商铺专属,住宅类为 NULL
    shop_depth      NUMERIC(6,2),              -- 进深(米;商铺专属)
    shop_height     NUMERIC(6,2),              -- 层高(米;商铺专属)
    shop_location   VARCHAR(20)                                  -- 商铺位置类型street=沿街 / mall=商场内 / residential=住宅底商 / ground_floor=楼栋底层 / complex=综合体(商铺专属)
                    CHECK (shop_location IS NULL OR
                           shop_location IN ('street','mall','residential',
                                            'ground_floor','complex')),

    -- ── 房屋状态 ──
    house_status    VARCHAR(20)                                  -- 房屋现状owner_occupied=业主自住 / vacant=空置 / tenant_occupied=租客租住 / unknown=未知;影响带看安排
                    CHECK (house_status IN ('owner_occupied','vacant',
                                           'tenant_occupied','unknown')),
    viewing_time    VARCHAR(20)                                  -- 看房时间安排anytime=随时可看 / by_appointment=提前预约 / inconvenient=不方便看
                    CHECK (viewing_time IN ('anytime','by_appointment','inconvenient')),

    -- ── 等级与标签 ──
    grade           VARCHAR(10)                                  -- 房源等级业主出售意向A=急迫 / B=较强 / C=一般 / D=较弱(详见 ENUMS.md §grade
                    CHECK (grade IN ('A','B','C','D')),

    -- ── 交易属性 ──
    ownership_years         VARCHAR(30),       -- 房本年限不满2年/满2年/满5年 等(影响交易税费)
    ownership_years_detail  VARCHAR(20),       -- 房本年限辅助说明:满五/不满五(与 ownership_years 组合使用)
    ownership_nature        VARCHAR(20)                          -- 产权性质commercial=商品房 / reform_housing=房改房 / collective=集资房 / economic=经济活用房
                    CHECK (ownership_nature IS NULL OR
                           ownership_nature IN ('commercial','reform_housing',
                                               'collective','economic')),
    is_only_house   BOOLEAN,                   -- 是否唯一住房true=唯一 / false=非唯一 / NULL=未确认;影响交易税费计算
    payment_method  VARCHAR(30)                                  -- 购房付款方式full=一次付清 / mortgage=按揭付款 / installment=分批次付款 / advance=垫资解按
                    CHECK (payment_method IS NULL OR
                           payment_method IN ('full','mortgage','installment','advance')),
    tax_included    VARCHAR(10)                                  -- 包税费方式each_party=各付 / net=到手 / inclusive=包税
                    CHECK (tax_included IS NULL OR
                           tax_included IN ('each_party','net','inclusive')),
    has_mortgage    BOOLEAN,                                     -- 是否有抵押true=有抵押 / false=无 / NULL=未确认
    has_loan        BOOLEAN,                                     -- 是否有贷款未还清true=有 / false=无 / NULL=未确认
    has_seal        BOOLEAN,                                     -- 是否被查封true=有查封 / false=无 / NULL=未确认
    has_restriction BOOLEAN,                                     -- 是否有限制其他限制true=有 / false=无 / NULL=未确认
    original_price  NUMERIC(12,2),             -- 原购价(万元;业主当年购入价,用于计算增值)
    sale_reason     TEXT,                      -- 售房原因业主出售理由最多200字如"置换"

    -- ── 营销备注 ──
    remarks         TEXT,                      -- 房源备注经纪人内部备注最多500字不对外展示

    -- ── 相关方 ──
    first_recorder_id UUID REFERENCES staff(id) ON DELETE SET NULL,  -- 首录方(最初录入该房源的经纪人;人员离职后置 NULL
    number_holder_id  UUID REFERENCES staff(id) ON DELETE SET NULL,  -- 号码方(持有业主联系号码的经纪人;变更需走审批流)
    seller_agent_id   UUID REFERENCES staff(id) ON DELETE SET NULL,  -- 出售方(负责出售跟进的经纪人)
    buyer_agent_id    UUID REFERENCES staff(id) ON DELETE SET NULL,  -- 实买方(促成成交的买方经纪人)

    -- ── 来源 ──
    source          VARCHAR(50),               -- 房源来源渠道(枚举值由 lookup_items 维护,如:门店拓客/转介绍/网络等)

    -- ── 维护完成度冗余缓存Celery 定期重算)──
    completeness_score SMALLINT NOT NULL DEFAULT 0,  -- 房源维护完成度评分0-100由 Celery 异步计算,非实时;前端列表页展示徽章)

    -- ── 时间轨迹 ──
    listed_at           TIMESTAMPTZ,           -- 最近一次挂牌时间(每次重新挂牌时更新)
    last_followed_at    TIMESTAMPTZ,           -- 最后跟进时间(冗余字段,由触发器自动维护,加速超时未跟进排序)
    created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),   -- 记录创建时间(系统自动)
    updated_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),   -- 记录最后更新时间(系统自动)
    deleted_at          TIMESTAMPTZ,                          -- 软删除时间戳NULL=未删除,非 NULL=已软删除
    created_by          UUID REFERENCES staff(id) ON DELETE SET NULL,  -- 创建人(操作员工)
    updated_by          UUID REFERENCES staff(id) ON DELETE SET NULL,  -- 最后修改人(操作员工)

    -- ── 全文检索向量 ──
    search_vector   TSVECTOR,                  -- 全文检索向量(由触发器 trg_property_search_vector 自动维护,覆盖栋号/单元/房号/备注)

    -- ── 乐观锁 ──
    version         INTEGER NOT NULL DEFAULT 1   -- 乐观锁版本号(每次 UPDATE 必须 +1应用层检测 0 行受影响时抛 ConflictError
);

-- ── 索引策略 ──

-- 核心列表过滤status + attribute + type
CREATE INDEX idx_properties_status_attr ON properties(status, attribute, property_type)
    WHERE deleted_at IS NULL;

-- 区域筛选
CREATE INDEX idx_properties_complex ON properties(complex_id)
    WHERE deleted_at IS NULL;

-- 售价排序
CREATE INDEX idx_properties_sale_price ON properties(sale_price DESC NULLS LAST)
    WHERE deleted_at IS NULL AND status IN ('for_sale','for_sale_rent');

-- 面积区间
CREATE INDEX idx_properties_area ON properties(area)
    WHERE deleted_at IS NULL;

-- 挂牌时间倒序
CREATE INDEX idx_properties_listed_at ON properties(listed_at DESC NULLS LAST)
    WHERE deleted_at IS NULL;

-- 超时跟进检测
CREATE INDEX idx_properties_last_followed ON properties(last_followed_at DESC NULLS LAST)
    WHERE deleted_at IS NULL;

-- 户型筛选
CREATE INDEX idx_properties_bedroom ON properties(bedroom_count)
    WHERE deleted_at IS NULL;

-- 等级筛选
CREATE INDEX idx_properties_grade ON properties(grade)
    WHERE deleted_at IS NULL;

-- 完成度排序
CREATE INDEX idx_properties_completeness ON properties(completeness_score)
    WHERE deleted_at IS NULL;

-- 全文搜索
CREATE INDEX idx_properties_search ON properties USING gin(search_vector);

-- 相关方快速定位
CREATE INDEX idx_properties_seller_agent ON properties(seller_agent_id)
    WHERE deleted_at IS NULL;
CREATE INDEX idx_properties_number_holder ON properties(number_holder_id)
    WHERE deleted_at IS NULL;

-- 列表默认排序status + listed_at
CREATE INDEX idx_properties_list_default ON properties(status, listed_at DESC NULLS LAST)
    WHERE deleted_at IS NULL;

-- 高频复合索引status + attribute + complex_id + sale_price
CREATE INDEX idx_properties_list_composite ON properties
    (status, attribute, complex_id, sale_price DESC NULLS LAST)
    WHERE deleted_at IS NULL;

-- 个人仪表板(与我相关)
CREATE INDEX idx_properties_my_properties ON properties
    (seller_agent_id, status, listed_at DESC NULLS LAST)
    WHERE deleted_at IS NULL;

4.2 property_contacts联系人

-- ============================================================
-- 业主/联系人:手机号加密存储,哈希值支持重复检测
-- 安全要点:任何查看明文号码的行为均触发审计日志
-- ============================================================

CREATE TABLE property_contacts (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),  -- 主键(系统生成,业务无关)
    property_id     UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE,  -- 所属房源(关联 properties 表;房源删除时联级删除)

    name            VARCHAR(50) NOT NULL,      -- 联系人姓名(如"张先生";业主或其代理人的真实姓名)
    gender          VARCHAR(10) NOT NULL DEFAULT 'male'  -- 性别male=先生 / female=女士;默认先生
                    CHECK (gender IN ('male','female')),
    identity        VARCHAR(20) NOT NULL DEFAULT 'contact'  -- 联系人身份owner=业主 / contact=联系人 / subletter=二房东 / tenant=租客 / agent=代理人 / corporate=企业法人;默认联系人
                    CHECK (identity IN ('owner','contact','subletter',
                                       'tenant','agent','corporate')),
    -- owner=业主, contact=联系人, subletter=二房东, tenant=租客,
    -- agent=代理人, corporate=企业法人

    -- 手机号:加密存储 + 哈希索引
    phone_enc       BYTEA NOT NULL,            -- 手机号1密文AES-256-GCM 加密,不可直接查询)
    phone_hash      VARCHAR(64) NOT NULL,      -- 手机号1哈希SHA-256用于重复房源检测和精确查询
    phone2_enc      BYTEA,                     -- 手机号2密文AES-256-GCM 加密;选填)
    phone2_hash     VARCHAR(64),               -- 手机号2哈希SHA-256phone2_enc 存在时必填)

    wechat          VARCHAR(100),              -- 微信号(选填;无数据时前端展示"-"
    qq              VARCHAR(20),               -- QQ号选填无数据时前端展示"-"
    remarks         TEXT,                      -- 备注最多200字补充说明联系人情况

    -- 号码方标记(关联审批流)
    is_number_holder            BOOLEAN NOT NULL DEFAULT FALSE,  -- 是否为号码方true=是号码方(审批通过)/ false=否;号码方变更须走审批流
    number_holder_approved_at   TIMESTAMPTZ,                     -- 号码方审批通过时间NULL=尚未成为号码方

    sort_order      INTEGER NOT NULL DEFAULT 0,  -- 排序权重(数值越小越靠前;控制联系人在面板中的显示顺序)
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),          -- 记录创建时间(系统自动)
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),          -- 记录最后更新时间(系统自动)
    deleted_at      TIMESTAMPTZ,                                 -- 软删除时间戳NULL=未删除,非 NULL=已软删除
    created_by      UUID REFERENCES staff(id) ON DELETE SET NULL,  -- 创建人(操作员工)
    updated_by      UUID REFERENCES staff(id) ON DELETE SET NULL   -- 最后修改人(操作员工)
);

CREATE INDEX idx_contacts_property ON property_contacts(property_id)
    WHERE deleted_at IS NULL;

-- 关键:手机号哈希全局索引(重复房源检测)
CREATE INDEX idx_contacts_phone_hash ON property_contacts(phone_hash)
    WHERE deleted_at IS NULL;
CREATE INDEX idx_contacts_phone2_hash ON property_contacts(phone2_hash)
    WHERE phone2_hash IS NOT NULL AND deleted_at IS NULL;

4.3 listing_histories挂牌历史

-- ============================================================
-- 挂牌历史:记录房源每次上架的完整快照
-- 注意:无 deleted_at不可删除append-only合规要求
-- ============================================================

CREATE TABLE listing_histories (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),  -- 主键(系统生成,业务无关)
    property_id     UUID NOT NULL REFERENCES properties(id) ON DELETE RESTRICT,  -- 所属房源(关联 properties 表;禁止级联删除,保留历史)

    listing_type    VARCHAR(20) NOT NULL                         -- 挂牌类型for_sale=出售挂牌 / for_rent=出租挂牌
                    CHECK (listing_type IN ('for_sale','for_rent')),
    status          VARCHAR(20) NOT NULL DEFAULT 'active'        -- 挂牌状态active=挂牌中 / ended=已结束
                    CHECK (status IN ('active','ended')),

    -- 价格快照
    sale_price      NUMERIC(12,2),             -- 本次挂牌售价快照(万元;出售挂牌时记录)
    rent_price      NUMERIC(10,2),             -- 本次挂牌租价快照(元/月;出租挂牌时记录)
    sale_unit_price NUMERIC(10,2),             -- 本次挂牌售价单价(元/m²由 sale_price ÷ area 计算后存储)

    -- 交易信息快照
    ownership_years VARCHAR(30),               -- 本次挂牌时的房本年限快照(如"满2年"
    is_only_house   BOOLEAN,                   -- 本次挂牌时的唯一住房状态快照
    tax_included    VARCHAR(10),               -- 本次挂牌时的包税费方式快照each_party=各付 / net=到手 / inclusive=包税)
    sale_reason     TEXT,                      -- 本次挂牌时的售房原因快照

    -- 经纪人快照(防止人员变动后丢失历史数据)
    seller_agent_id UUID REFERENCES staff(id) ON DELETE SET NULL,  -- 本次挂牌的出售经纪人(人员离职后置 NULL但 snapshot 保留)
    seller_agent_snapshot JSONB,              -- 出售经纪人快照({name, store_group, org_unit_name};防止人员变动后数据丢失)

    started_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),  -- 本次挂牌开始时间
    ended_at        TIMESTAMPTZ,                         -- 本次挂牌结束时间NULL=当前仍在挂牌中

    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()   -- 记录创建时间(系统自动)
    -- 无 deleted_at此表记录不可删除
);

CREATE INDEX idx_listing_histories_property ON listing_histories(property_id);
CREATE INDEX idx_listing_histories_active ON listing_histories(property_id)
    WHERE status = 'active';

4.4 price_changes调价记录

-- ============================================================
-- 调价记录支持折线图展示不可删除append-only
-- ============================================================

CREATE TABLE price_changes (
    id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),  -- 主键(系统生成,业务无关)
    property_id         UUID NOT NULL REFERENCES properties(id) ON DELETE RESTRICT,  -- 所属房源(关联 properties 表;禁止级联删除,保留调价历史)

    old_sale_price      NUMERIC(12,2),         -- 调价前挂牌售价万元NULL=首次定价)
    new_sale_price      NUMERIC(12,2),         -- 调价后挂牌售价(万元)
    old_bottom_price    NUMERIC(12,2),         -- 调价前售底价万元NULL=未设置)
    new_bottom_price    NUMERIC(12,2),         -- 调价后售底价万元NULL=本次不变更底价)
    old_record_price    NUMERIC(12,2),         -- 调价前备案/核验价万元NULL=未设置)
    new_record_price    NUMERIC(12,2),         -- 调价后备案/核验价万元NULL=本次不变更)
    old_rent_price      NUMERIC(10,2),         -- 调价前挂牌租价(元/月NULL=非出租类或未设置)
    new_rent_price      NUMERIC(10,2),         -- 调价后挂牌租价(元/月)

    change_reason       TEXT NOT NULL,         -- 调价原因必填最多200字如"业主主动降价"
    changed_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),  -- 调价操作时间
    changed_by          UUID NOT NULL REFERENCES staff(id) ON DELETE RESTRICT  -- 操作人(关联 staff 表;禁止置 NULL保留审计追溯
    -- 无 deleted_at不可删除
);

CREATE INDEX idx_price_changes_property ON price_changes(property_id);
CREATE INDEX idx_price_changes_time ON price_changes(property_id, changed_at DESC);

4.5 follow_logs跟进日志

-- ============================================================
-- 跟进日志:系统最高写入频率的表
-- 6 种类型written / modified / sensitive_op / sensitive_view / other / system
-- sensitive_view 类型is_deletable=FALSE合规不可删
-- ============================================================

CREATE TABLE follow_logs (
    id              UUID NOT NULL DEFAULT gen_random_uuid(),  -- 主键(与 created_at 组成复合主键,分区表要求)
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),  -- 记录创建时间(分区键,必须在最前声明;系统自动)
    property_id     UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE,  -- 所属房源(关联 properties 表)

    log_type        VARCHAR(30) NOT NULL                         -- 跟进日志类型written=经纪人主动写入 / modified=字段变更自动生成 / sensitive_op=敏感操作跟进 / sensitive_view=敏感信息查看(不可删)/ other=其他 / system=系统日志
                    CHECK (log_type IN ('written','modified','sensitive_op',
                                       'sensitive_view','other','system')),

    -- 写入跟进专用字段
    purpose         VARCHAR(50),               -- 跟进目的(枚举值由 lookup_items 维护,如:电话/业主跟进/议价/带看;仅 written 类型使用)
    content         TEXT,                      -- 跟进内容最少6字最多500字仅 written 类型必填)

    -- AI 辅助判断标签
    ai_tag          VARCHAR(20)                                  -- AI 辅助标签ai_for_sale=AI判断业主在售 / ai_not_for_sale=AI判断业主不售由系统根据跟进内容智能分析后打标
                    CHECK (ai_tag IS NULL OR ai_tag IN ('ai_for_sale','ai_not_for_sale')),

    -- 修改跟进专用字段
    change_detail   JSONB,                     -- 字段变更明细(格式:{\"field\": \"sale_price\", \"old\": 850, \"new\": 800, \"label\": \"售价\"}modified 类型使用)
    -- 格式:{"field": "sale_price", "old": 850, "new": 800, "label": "售价"}

    -- 系统显示标签
    log_tag         VARCHAR(50),               -- 前端展示标签(如:查看号码/图片下载/改状态/改价格/改等级/修改相关方;对应跟进日志时间线显示的【方括号标签】)
    -- 如:查看号码/图片下载/改状态/改价格/改等级/修改相关方

    -- 可见性控制
    is_public       BOOLEAN NOT NULL DEFAULT TRUE,  -- 是否公开true=全员可见 / false=仅本人及上级可见
    -- FALSE = 仅本人及上级可见

    -- 操作人
    operator_id     UUID REFERENCES staff(id) ON DELETE SET NULL,  -- 操作人(关联 staff 表;人员离职后置 NULL但 snapshot 保留)
    operator_snapshot JSONB,                   -- 操作人快照({name, role, org_unit_name, store_group};防止人员离职后丢失显示信息)

    -- 是否可删除sensitive_view = FALSE合规强制
    is_deletable    BOOLEAN NOT NULL DEFAULT TRUE,  -- 是否可软删除false=敏感信息查看类型,合规要求不可删除

    deleted_at      TIMESTAMPTZ,               -- 软删除时间戳;仅 is_deletable=TRUE 时可软删NULL=未删除

    PRIMARY KEY (id, created_at)               -- 分区表主键必须包含分区键
) PARTITION BY RANGE (created_at);

-- ── 按月自动建分区(由 partition_maintenance_task Celery 任务维护)──
-- 示例:初始建立当前月 + 下一个月的分区
CREATE TABLE follow_logs_2026_04 PARTITION OF follow_logs
    FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
CREATE TABLE follow_logs_2026_05 PARTITION OF follow_logs
    FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
-- 默认分区:兜底,防止超出已建分区范围导致写入失败
CREATE TABLE follow_logs_default PARTITION OF follow_logs DEFAULT;

-- 时间线展示(核心)
CREATE INDEX idx_follow_logs_property_time ON follow_logs(property_id, created_at DESC)
    WHERE deleted_at IS NULL;

-- 6个 Tab 过滤
CREATE INDEX idx_follow_logs_type ON follow_logs(property_id, log_type, created_at DESC)
    WHERE deleted_at IS NULL;

-- 操作员过滤
CREATE INDEX idx_follow_logs_operator ON follow_logs(operator_id, created_at DESC)
    WHERE deleted_at IS NULL;

-- 合规审计(敏感类型专用)
CREATE INDEX idx_follow_logs_sensitive ON follow_logs(property_id, created_at DESC)
    WHERE log_type IN ('sensitive_view','sensitive_op');

4.6 follow_log_attachments跟进附件

CREATE TABLE follow_log_attachments (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),  -- 主键(系统生成,业务无关)
    follow_log_id   UUID NOT NULL REFERENCES follow_logs(id) ON DELETE CASCADE,  -- 所属跟进日志(关联 follow_logs 表;日志删除时联级删除)

    file_key        TEXT NOT NULL,             -- 图片存储路径Cloudflare R2 对象路径)
    file_name       VARCHAR(255) NOT NULL,     -- 原始文件名(用户上传时的文件名)
    file_size       INTEGER NOT NULL,          -- 文件大小bytes最大 20MB = 20971520
    file_type       VARCHAR(10)                                  -- 文件格式bmp / jpg / png / svg / gifPRD 限定格式)
                    CHECK (file_type IN ('bmp','jpg','png','svg','gif')),
    sort_order      SMALLINT NOT NULL DEFAULT 0,  -- 排序权重(控制同一跟进附件的显示顺序)
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()  -- 上传时间(系统自动)
);

CREATE INDEX idx_follow_attachments_log ON follow_log_attachments(follow_log_id);

4.7 follow_log_recordings跟进录音

CREATE TABLE follow_log_recordings (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),  -- 主键(系统生成,业务无关)
    follow_log_id   UUID NOT NULL REFERENCES follow_logs(id) ON DELETE CASCADE,  -- 所属跟进日志(关联 follow_logs 表;日志删除时联级删除)

    file_key        TEXT NOT NULL,             -- 录音文件存储路径Cloudflare R2 对象路径)
    duration_seconds INTEGER,                 -- 录音时长(秒;可空,上传时若能解析则填写)
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()  -- 上传时间(系统自动)
);

CREATE INDEX idx_follow_recordings_log ON follow_log_recordings(follow_log_id);

4.8 property_keys钥匙管理

CREATE TABLE property_keys (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),  -- 主键(系统生成,业务无关)
    property_id     UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE,  -- 所属房源(关联 properties 表)

    key_type        VARCHAR(20) NOT NULL                         -- 钥匙类型mechanical=机械钥匙 / password=密码(如密码门锁)
                    CHECK (key_type IN ('mechanical','password')),

    -- 钥匙持有方
    holder_id       UUID REFERENCES staff(id) ON DELETE SET NULL,  -- 持有人(关联 staff 表;人员离职后置 NULL
    holder_snapshot JSONB,                     -- 持有人快照({name, store_group};防止人员离职后丢失显示信息)
    storage_unit_id UUID REFERENCES org_units(id) ON DELETE SET NULL,  -- 保管部门(关联 org_units 表;钥匙存放在哪个部门)

    -- 他司钥匙标记
    is_other_agency     BOOLEAN NOT NULL DEFAULT FALSE,          -- 是否为他中介公司的钥匙true=是他司钥匙 / false=本司钥匙
    other_agency_info   VARCHAR(30),           -- 他司中介信息最多30字is_other_agency=true 时填写,如"链家"

    remarks         TEXT,                      -- 备注最多200字如密码内容等补充说明

    is_active       BOOLEAN NOT NULL DEFAULT TRUE,  -- 是否有效true=在管中 / false=已归还或失效
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),  -- 记录创建时间(系统自动)
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),  -- 记录最后更新时间(系统自动)
    created_by      UUID REFERENCES staff(id) ON DELETE SET NULL  -- 创建人(操作员工)
);

CREATE INDEX idx_property_keys_property ON property_keys(property_id)
    WHERE is_active = TRUE;

4.9 key_attachments钥匙附件

CREATE TABLE key_attachments (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),  -- 主键(系统生成,业务无关)
    key_id          UUID NOT NULL REFERENCES property_keys(id) ON DELETE CASCADE,  -- 所属钥匙记录(关联 property_keys 表;钥匙删除时联级删除)

    file_key        TEXT NOT NULL,             -- 附件存储路径Cloudflare R2 对象路径)
    file_name       VARCHAR(255) NOT NULL,     -- 原始文件名
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()  -- 上传时间(系统自动)
);

CREATE INDEX idx_key_attachments_key ON key_attachments(key_id);

4.10 commissions委托管理

CREATE TABLE commissions (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),  -- 主键(系统生成,业务无关)
    property_id     UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE,  -- 所属房源(关联 properties 表)

    commission_type VARCHAR(50) NOT NULL,      -- 委托类型(独家委托/非独家委托;由 lookup_items 维护)
    period_start    DATE NOT NULL,             -- 委托开始日期
    period_end      DATE,                      -- 委托结束日期is_open_ended=true 时为 NULL
    is_open_ended   BOOLEAN NOT NULL DEFAULT FALSE,  -- 是否无固定结束日期true=长期委托 / false=有截止日期

    -- 委托经纪人
    agent_id        UUID REFERENCES staff(id) ON DELETE SET NULL,  -- 委托经纪人(关联 staff 表;人员离职后置 NULL
    agent_snapshot  JSONB,                     -- 经纪人快照({name, store_group};防止人员变动后数据丢失)

    signing_method  VARCHAR(50),               -- 签约方式(选择后动态展示委托书模板)

    -- 委托人(产权人)信息
    owner_type      VARCHAR(20) NOT NULL DEFAULT 'owner'           -- 委托人类型owner=产权人本人 / authorized_third=被授权第三方
                    CHECK (owner_type IN ('owner','authorized_third')),
    property_owner_contact_id UUID REFERENCES property_contacts(id) ON DELETE SET NULL,  -- 关联联系人property_contacts 表;若委托人已录入联系人)
    owner_name          VARCHAR(50),           -- 委托人姓名
    owner_id_type       VARCHAR(20),           -- 委托人证件类型(如:身份证/护照)
    owner_id_number     VARCHAR(50),           -- 委托人证件号明文(仅供参考,加密版本见下方)
    owner_id_number_enc BYTEA,                 -- 委托人证件号密文AES-256-GCM 加密)

    remarks         TEXT,                      -- 备注最多200字

    status          VARCHAR(20) NOT NULL DEFAULT 'active'          -- 委托状态active=有效 / expired=已过期 / cancelled=已取消
                    CHECK (status IN ('active','expired','cancelled')),

    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),  -- 记录创建时间(系统自动)
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),  -- 记录最后更新时间(系统自动)
    created_by      UUID REFERENCES staff(id) ON DELETE SET NULL  -- 创建人(操作员工)
);

CREATE INDEX idx_commissions_property ON commissions(property_id);
CREATE INDEX idx_commissions_active ON commissions(property_id)
    WHERE status = 'active';

4.11 commission_attachments委托附件

CREATE TABLE commission_attachments (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),  -- 主键(系统生成,业务无关)
    commission_id   UUID NOT NULL REFERENCES commissions(id) ON DELETE CASCADE,  -- 所属委托(关联 commissions 表;委托删除时联级删除)

    category        VARCHAR(20) NOT NULL                         -- 附件分类id_card=身份证 / property_cert=产权证书 / commission_letter=委托书 / other=其他材料
                    CHECK (category IN ('id_card','property_cert',
                                       'commission_letter','other')),
    file_key        TEXT NOT NULL,             -- 附件存储路径Cloudflare R2 对象路径)
    file_name       VARCHAR(255) NOT NULL,     -- 原始文件名
    file_size       INTEGER,                   -- 文件大小bytes
    sort_order      SMALLINT NOT NULL DEFAULT 0,  -- 排序权重(数值越小越靠前)
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()  -- 上传时间(系统自动)
);

CREATE INDEX idx_commission_attachments_commission ON commission_attachments(commission_id);

4.12 field_surveys实勘管理

CREATE TABLE field_surveys (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),  -- 主键(系统生成,业务无关)
    property_id     UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE,  -- 所属房源(关联 properties 表)

    status          VARCHAR(10) NOT NULL DEFAULT 'draft'         -- 实勘状态draft=草稿(未提交)/ submitted=已提交(已完成)
                    CHECK (status IN ('draft','submitted')),

    -- GPS 定位(实勘打卡)
    gps_latitude    NUMERIC(10,7),             -- GPS 纬度实勘打卡位置精度7位小数
    gps_longitude   NUMERIC(10,7),             -- GPS 经度实勘打卡位置精度7位小数
    gps_accuracy    NUMERIC(6,2),              -- GPS 精度(米;标注定位误差)

    description     TEXT,                      -- 实勘说明最多200字经纪人现场情况描述

    submitted_at    TIMESTAMPTZ,               -- 提交时间status 变为 submitted 时记录NULL=尚未提交)
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),  -- 记录创建时间(系统自动)
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),  -- 记录最后更新时间(系统自动)
    created_by      UUID NOT NULL REFERENCES staff(id) ON DELETE RESTRICT  -- 实勘人(操作员工;禁止置 NULL 保留审计)
);

CREATE INDEX idx_field_surveys_property ON field_surveys(property_id);
CREATE INDEX idx_field_surveys_submitted ON field_surveys(property_id)
    WHERE status = 'submitted';

4.13 survey_photos实勘照片

CREATE TABLE survey_photos (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),  -- 主键(系统生成,业务无关)
    survey_id       UUID NOT NULL REFERENCES field_surveys(id) ON DELETE CASCADE,  -- 所属实勘(关联 field_surveys 表;实勘删除时联级删除)

    category        VARCHAR(20) NOT NULL                         -- 照片空间分类layout=户型图 / living_room=客厅 / dining_room=餐厅 / bedroom=卧室 / bathroom=卫生间 / kitchen=厨房 / entrance=门厅 / balcony=阳台 / study=书房 / indoor_other=室内其他 / outdoor=外景
                    CHECK (category IN ('layout','living_room','dining_room',
                                       'bedroom','bathroom','kitchen',
                                       'entrance','balcony','study',
                                       'indoor_other','outdoor')),
    file_key        TEXT NOT NULL,             -- 原图存储路径Cloudflare R2 对象路径)
    thumbnail_key   TEXT,                      -- 缩略图路径Cloudflare Images 自动生成)
    file_size       INTEGER,                   -- 文件大小bytes
    width           INTEGER,                   -- 图片宽度(像素;上传时解析)
    height          INTEGER,                   -- 图片高度(像素;上传时解析)
    sort_order      SMALLINT NOT NULL DEFAULT 0,  -- 排序权重(同一空间分类内,数值越小越靠前)
    is_vr_screenshot BOOLEAN NOT NULL DEFAULT FALSE,  -- 是否为VR截图true=全景/VR截图区别于普通实拍照片
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()  -- 上传时间(系统自动)
);

CREATE INDEX idx_survey_photos_survey ON survey_photos(survey_id);
CREATE INDEX idx_survey_photos_category ON survey_photos(survey_id, category);

4.14 property_photos房源图片

-- ============================================================
-- 房源图片:经纪人自主上传和管理,与实勘照片分离存储
-- 封面限1张唯一约束全景类型单独处理
-- ============================================================

CREATE TABLE property_photos (
    id              UUID NOT NULL DEFAULT gen_random_uuid(),  -- 主键(与 created_at 组成复合主键,分区表要求)
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),  -- 上传时间(分区键;系统自动)
    property_id     UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE,  -- 所属房源(关联 properties 表)

    category        VARCHAR(20) NOT NULL                         -- 照片分类cover=封面 / entrance=门厅 / living_room=客厅 / dining_room=餐厅 / bedroom=卧室 / bathroom=卫生间 / kitchen=厨房 / balcony=阳台 / study=书房 / indoor_other=室内其他 / outdoor=外景 / panorama=全景
                    CHECK (category IN ('cover','entrance','living_room',
                                       'dining_room','bedroom','bathroom',
                                       'kitchen','balcony','study',
                                       'indoor_other','outdoor','panorama')),

    file_key        TEXT NOT NULL,             -- 原图存储路径Cloudflare R2/S3 对象路径)
    thumbnail_key   TEXT,                      -- 缩略图路径Cloudflare Images 自动生成)
    file_name       VARCHAR(255),              -- 原始文件名
    file_size       INTEGER,                   -- 文件大小bytes
    width           INTEGER,                   -- 图片宽度(像素;上传时解析)
    height          INTEGER,                   -- 图片高度(像素;上传时解析)

    is_cover        BOOLEAN NOT NULL DEFAULT FALSE,  -- 是否为封面图true=封面;每套房源只能有一张封面(唯一约束保证)
    sort_order      SMALLINT NOT NULL DEFAULT 0,     -- 排序权重(同一房源内,数值越小越靠前)

    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),  -- 最后更新时间(系统自动)
    created_by      UUID REFERENCES staff(id) ON DELETE SET NULL,  -- 上传人(操作员工)

    PRIMARY KEY (id, created_at)               -- 分区表主键必须包含分区键
) PARTITION BY RANGE (created_at);

CREATE TABLE property_photos_2026_04 PARTITION OF property_photos
    FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
CREATE TABLE property_photos_2026_05 PARTITION OF property_photos
    FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE property_photos_default PARTITION OF property_photos DEFAULT;

CREATE INDEX idx_property_photos_property ON property_photos(property_id);
CREATE INDEX idx_property_photos_cover ON property_photos(property_id)
    WHERE is_cover = TRUE;
CREATE INDEX idx_property_photos_category ON property_photos(property_id, category);

-- 每套房源只能有一张封面
CREATE UNIQUE INDEX idx_property_photos_unique_cover
    ON property_photos(property_id)
    WHERE is_cover = TRUE;

4.15 property_attachments房源附件

CREATE TABLE property_attachments (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),  -- 主键(系统生成,业务无关)
    property_id     UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE,  -- 所属房源(关联 properties 表)

    category        VARCHAR(20) NOT NULL DEFAULT 'other'         -- 附件分类id_card=身份证 / property_cert=产权证书 / commission_letter=委托书 / other=其他材料
                    CHECK (category IN ('id_card','property_cert',
                                       'commission_letter','other')),

    file_key        TEXT NOT NULL,             -- 附件存储路径Cloudflare R2 对象路径)
    file_name       VARCHAR(255) NOT NULL,     -- 原始文件名
    file_size       INTEGER NOT NULL,          -- 文件大小bytes
    file_type       VARCHAR(50),               -- MIME 类型(如 application/pdf、image/jpeg

    sort_order      SMALLINT NOT NULL DEFAULT 0,  -- 排序权重(控制同一房源附件的显示顺序)
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),  -- 上传时间(系统自动)
    created_by      UUID REFERENCES staff(id) ON DELETE SET NULL  -- 上传人(操作员工)
);

CREATE INDEX idx_property_attachments_property ON property_attachments(property_id);
CREATE INDEX idx_property_attachments_category ON property_attachments(property_id, category);

4.16 property_marketing营销信息

-- 1:1 扩展表,营销标题/核心卖点/业主心态/户型介绍/小区介绍
CREATE TABLE property_marketing (
    id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),  -- 主键(系统生成,业务无关)
    property_id         UUID NOT NULL UNIQUE REFERENCES properties(id) ON DELETE CASCADE,  -- 所属房源1:1 关联 properties 表)

    marketing_title     VARCHAR(30),           -- 营销标题0-30字前端发房时展示给买家的吸睛标题
    core_selling_points TEXT,                  -- 核心卖点最多200字展示给买家的重点卖点说明
    owner_attitude      TEXT,                  -- 业主心态最多200字仅内部可见描述业主议价空间和心理状态
    layout_description  TEXT,                  -- 户型介绍最多200字房源户型特点描述面向买家展示
    complex_description TEXT,                  -- 小区介绍最多200字楼盘/小区周边配套描述)

    -- AI 生成标记
    ai_generated_points     BOOLEAN NOT NULL DEFAULT FALSE,  -- 核心卖点是否由 AI 生成true=AI辅助生成经纪人确认后使用
    ai_generated_attitude   BOOLEAN NOT NULL DEFAULT FALSE,  -- 业主心态是否由 AI 生成true=AI辅助生成

    updated_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),  -- 最后更新时间(系统自动)
    updated_by          UUID REFERENCES staff(id) ON DELETE SET NULL  -- 最后修改人(操作员工)
);

4.17 property_certificates产证信息

-- 1:1 扩展表,存储产权证相关信息
CREATE TABLE property_certificates (
    id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),  -- 主键(系统生成,业务无关)
    property_id         UUID NOT NULL UNIQUE REFERENCES properties(id) ON DELETE CASCADE,  -- 所属房源1:1 关联 properties 表)

    owner_name          VARCHAR(100),          -- 产权人姓名(产权证书上登记的所有权人)
    owner_id_number     VARCHAR(50),           -- 证件号码(身份证号/统一社会信用代码等)
    owner_cert_type     VARCHAR(20),           -- 证件类型(如:身份证/护照/营业执照)
    property_location   VARCHAR(500),          -- 房屋坐落产权证书上的完整地址最多500字

    cert_status         VARCHAR(30),           -- 产证状态(如:已过户/抵押中/查封/正常)
    cert_no             VARCHAR(100),          -- 产权证号(不动产权证书编号)
    first_registered_at DATE,                  -- 首次登记时间(产权证上的初始登记日期)
    ownership_nature    VARCHAR(30),           -- 权属性质(如:商品房/经济适用房/回迁房)
    land_nature         VARCHAR(30),           -- 土地性质(如:国有/集体/划拨/出让)

    updated_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),  -- 最后更新时间(系统自动)
    updated_by          UUID REFERENCES staff(id) ON DELETE SET NULL  -- 最后修改人(操作员工)
);

4.18 property_completeness维护完成度

-- ============================================================
-- 维护完成度快照1:1
-- 各维度得分由 Celery 任务异步计算后写入,非实时
-- properties.completeness_score 为冗余总分,供列表排序用
-- ============================================================

CREATE TABLE property_completeness (
    id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),  -- 主键(系统生成,业务无关)
    property_id         UUID NOT NULL UNIQUE REFERENCES properties(id) ON DELETE CASCADE,  -- 所属房源1:1 关联 properties 表)

    score_core_info     SMALLINT NOT NULL DEFAULT 0,   -- 重点信息得分满分8包含房源核心字段完整度
    score_attachment    SMALLINT NOT NULL DEFAULT 0,   -- 附件得分满分8身份证/产权证/委托书等材料上传情况)
    score_survey        SMALLINT NOT NULL DEFAULT 0,   -- 实勘得分满分16实勘照片和报告完整度
    score_vr            SMALLINT NOT NULL DEFAULT 0,   -- VR得分满分8VR/全景照片上传情况)
    score_key           SMALLINT NOT NULL DEFAULT 0,   -- 钥匙得分满分10钥匙托管情况
    score_commission    SMALLINT NOT NULL DEFAULT 0,   -- 委托得分满分10独家/普通委托书情况)
    score_verification  SMALLINT NOT NULL DEFAULT 0,   -- 验证得分满分7房源信息核实情况
    score_follow_up     SMALLINT NOT NULL DEFAULT 0,   -- 跟进得分满分8近期跟进记录情况
    score_viewing       SMALLINT NOT NULL DEFAULT 0,   -- 带看得分满分8带看记录完整度
    score_other         SMALLINT NOT NULL DEFAULT 0,   -- 其他得分满分7其他加分项
    total_score         SMALLINT NOT NULL DEFAULT 0,   -- 维护完成度总分0-100供列表排序用与 properties.completeness_score 冗余)

    calculated_at       TIMESTAMPTZ NOT NULL DEFAULT NOW()  -- 最近一次异步计算完成时间
);

4.19 property_tags / property_tag_relations标签

-- 标签字典(系统预置 + 运营自定义)
CREATE TABLE property_tags (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),  -- 主键(系统生成,业务无关)
    name        VARCHAR(50) NOT NULL,          -- 标签名称最多50字学区/地铁口/满五唯一)
    color       VARCHAR(7),                    -- 显示颜色HEX 色值,如 #FF5733前端标签徽章颜色
    is_system   BOOLEAN NOT NULL DEFAULT FALSE, -- 是否系统预置true=系统内置标签不可删除false=运营自定义标签可删
    sort_order  INTEGER NOT NULL DEFAULT 0,    -- 排序权重(数值越小越靠前)
    is_active   BOOLEAN NOT NULL DEFAULT TRUE  -- 是否启用false=已停用不再展示
);

-- 房源 ↔ 标签 多对多
CREATE TABLE property_tag_relations (
    property_id UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE,  -- 所属房源(关联 properties 表)
    tag_id      UUID NOT NULL REFERENCES property_tags(id) ON DELETE CASCADE,  -- 所属标签(关联 property_tags 表)
    PRIMARY KEY (property_id, tag_id)
);

CREATE INDEX idx_property_tags_property ON property_tag_relations(property_id);
CREATE INDEX idx_property_tags_tag ON property_tag_relations(tag_id);

4.20 property_favorites收藏

-- 经纪人收藏房源(快速访问)
CREATE TABLE property_favorites (
    staff_id    UUID NOT NULL REFERENCES staff(id) ON DELETE CASCADE,  -- 收藏人(关联 staff 表;员工注销时删除收藏记录)
    property_id UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE,  -- 收藏的房源(关联 properties 表)
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),  -- 收藏时间(系统自动)
    PRIMARY KEY (staff_id, property_id)
);

CREATE INDEX idx_property_favorites_staff ON property_favorites(staff_id);

4.21 property_protections保护房

-- 1:1标记某套房源是否受保护防止被他人抢单/公盘化)
CREATE TABLE property_protections (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),  -- 主键(系统生成,业务无关)
    property_id     UUID NOT NULL UNIQUE REFERENCES properties(id) ON DELETE CASCADE,  -- 所属房源1:1 关联 properties 表)

    is_protected    BOOLEAN NOT NULL DEFAULT FALSE,  -- 是否处于保护状态true=受保护(防止被他人抢单/公盘化)/ false=未保护
    reason          TEXT,                      -- 保护原因(说明为何启用保护)
    start_at        TIMESTAMPTZ,               -- 保护开始时间NULL=尚未生效)
    end_at          TIMESTAMPTZ,               -- 保护到期时间NULL=长期保护)
    set_by          UUID REFERENCES staff(id) ON DELETE SET NULL,  -- 设置人(关联 staff 表;人员离职后置 NULL
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()  -- 记录创建时间(系统自动)
);

4.22 number_holder_approvals号码方审批

-- 号码方变更审批流:经纪人申请,上级审批
CREATE TABLE number_holder_approvals (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),  -- 主键(系统生成,业务无关)
    property_id     UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE,  -- 所属房源(关联 properties 表)
    contact_id      UUID NOT NULL REFERENCES property_contacts(id) ON DELETE CASCADE,  -- 申请变更的联系方(关联 property_contacts 表;即号码方)

    applicant_id    UUID NOT NULL REFERENCES staff(id) ON DELETE RESTRICT,  -- 申请人(关联 staff 表;提交号码方变更申请的经纪人;禁止置 NULL 保留审计)
    approver_id     UUID REFERENCES staff(id) ON DELETE SET NULL,  -- 审批人(关联 staff 表;上级审批人;审批前为 NULL

    status          VARCHAR(20) NOT NULL DEFAULT 'pending'       -- 审批状态pending=待审批 / approved=已通过 / rejected=已驳回
                    CHECK (status IN ('pending','approved','rejected')),
    remarks         TEXT,                      -- 审批备注(审批人填写的意见或驳回原因)

    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),  -- 申请提交时间(系统自动)
    decided_at      TIMESTAMPTZ                -- 审批决定时间NULL=尚未审批)
);

CREATE INDEX idx_number_holder_approvals_status ON number_holder_approvals(status)
    WHERE status = 'pending';
CREATE INDEX idx_number_holder_approvals_property ON number_holder_approvals(property_id);

5 触发器

5.1 房源全文搜索向量自动维护

CREATE OR REPLACE FUNCTION update_property_search_vector()
RETURNS TRIGGER AS $$
BEGIN
    NEW.search_vector :=
        setweight(to_tsvector('simple', COALESCE(NEW.block_no, '') ||
                              ' ' || COALESCE(NEW.unit_no, '') ||
                              ' ' || COALESCE(NEW.room_no, '')), 'A') ||
        setweight(to_tsvector('simple', COALESCE(NEW.remarks, '')), 'C');
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_property_search_vector
    BEFORE INSERT OR UPDATE OF block_no, unit_no, room_no, remarks
    ON properties
    FOR EACH ROW EXECUTE FUNCTION update_property_search_vector();

5.2 last_followed_at 自动维护

-- 写入跟进日志时,自动更新 properties.last_followed_at加速超时检测排序
CREATE OR REPLACE FUNCTION update_property_last_followed()
RETURNS TRIGGER AS $$
BEGIN
    IF NEW.log_type = 'written' THEN
        UPDATE properties
        SET last_followed_at = NEW.created_at,
            updated_at = NOW()
        WHERE id = NEW.property_id;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_update_last_followed
    AFTER INSERT ON follow_logs
    FOR EACH ROW EXECUTE FUNCTION update_property_last_followed();

6 查询模式参考

6.1 房源列表页(高频)

-- 出售公盘,按挂牌时间倒序
SELECT p.id, p.status, p.attribute, p.sale_price, p.area,
       p.bedroom_count, p.floor, p.total_floors, p.completeness_score,
       p.listed_at
FROM   properties p
WHERE  p.status = 'for_sale'
  AND  p.attribute = 'public'
  AND  p.deleted_at IS NULL
ORDER  BY p.listed_at DESC NULLS LAST
LIMIT  20 OFFSET 0;
-- 命中索引idx_properties_list_default

6.2 区域筛选(楼盘 + 商圈下钻)

-- 某商圈下所有出售房源
SELECT p.*
FROM   properties p
JOIN   complexes c ON c.id = p.complex_id
JOIN   complex_business_areas cba ON cba.complex_id = c.id
WHERE  cba.business_area_id = :business_area_id
  AND  p.status IN ('for_sale','for_sale_rent')
  AND  p.deleted_at IS NULL
ORDER  BY p.listed_at DESC NULLS LAST;
-- 命中索引idx_properties_complex + complex_business_areas 索引

6.3 与我相关(经纪人仪表板)

SELECT * FROM properties
WHERE  seller_agent_id = :my_staff_id
  AND  status NOT IN ('sold','unlisted')
  AND  deleted_at IS NULL
ORDER  BY listed_at DESC NULLS LAST;
-- 命中索引idx_properties_my_properties

6.4 重复房源检测(录入前必查)

-- 通过联系人手机号哈希检测
SELECT pc.property_id, p.status, p.deleted_at
FROM   property_contacts pc
JOIN   properties p ON p.id = pc.property_id
WHERE  pc.phone_hash = SHA256(:input_phone)
  AND  pc.deleted_at IS NULL;
-- 命中索引idx_contacts_phone_hash

6.5 跟进日志时间线(详情页)

-- 房源详情页跟进 Tab全部类型时间倒序
SELECT fl.*, fla.file_key, fla.file_name
FROM   follow_logs fl
LEFT JOIN follow_log_attachments fla ON fla.follow_log_id = fl.id
WHERE  fl.property_id = :property_id
  AND  fl.deleted_at IS NULL
ORDER  BY fl.created_at DESC;
-- 命中索引idx_follow_logs_property_time

7 禁止操作

操作 影响表 原因
DELETE FROM listing_histories listing_histories 挂牌历史不可删除,合规审计要求
DELETE FROM price_changes price_changes 调价记录不可删除
UPDATE follow_logs SET deleted_at = NOW() WHERE is_deletable = FALSE follow_logs 敏感信息查看类型日志不可删除
直接修改 properties.completeness_score properties 完成度分数只由 Celery 任务计算更新,禁止 Application 层直接写入
直接修改 properties.last_followed_at properties 由触发器 trg_update_last_followed 自动维护
删除 property_tags WHERE is_system = TRUE property_tags 系统预置标签不可删除
明文存储手机号 property_contacts 手机号必须 AES-256-GCM 加密后存 phone_enc,同时更新 phone_hash

DATA_MODEL_PROPERTY.md — Fonrey 房产经纪管理系统房源模块数据模型 v1.0
下一步API 接口规范(房源模块 URL 设计 + Request/Response Schema