from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.indexes import GinIndex from django.contrib.postgres.search import SearchVectorField from django.db import models from core.enums import ( ComplexBuildingStructure, ComplexBuildingType, ComplexElectricityType, ComplexPhotoCategory, ComplexPropertyUsageType, ComplexWaterType, SchoolZoneType, ) from core.models.base import SoftDeleteModel, TimeStampedModel, UUIDPrimaryKeyModel class Complex(SoftDeleteModel): name = models.CharField( max_length=200, verbose_name="楼盘名称", help_text="标准楼盘名称,不可在编辑页直接修改(需走合并/申请流程)", ) district = models.ForeignKey( "region.District", null=True, blank=True, on_delete=models.SET_NULL, related_name="complexes", verbose_name="所属城区", ) address = models.CharField( max_length=500, blank=True, default="", verbose_name="详细地址", help_text="不可在编辑页修改,需走纠错流程", ) address_summary = models.CharField( max_length=100, blank=True, default="", verbose_name="概要地址", help_text='如「海波路1000弄」,可编辑', ) latitude = models.DecimalField( max_digits=10, decimal_places=7, null=True, blank=True, verbose_name="纬度", help_text="WGS84,完整度目标 ≥ 90%", ) longitude = models.DecimalField( max_digits=10, decimal_places=7, null=True, blank=True, verbose_name="经度", help_text="WGS84", ) property_usage_types = ArrayField( models.CharField(max_length=30, choices=ComplexPropertyUsageType.choices), default=list, blank=True, verbose_name="物业类型", help_text="多选:residential / villa / commercial_residential / commercial / office / other", ) building_structure = models.CharField( max_length=30, blank=True, default="", choices=ComplexBuildingStructure.choices, verbose_name="楼栋结构", help_text="unit_room=单元-房号 / other=其他", ) building_type = models.CharField( max_length=20, blank=True, default="", choices=ComplexBuildingType.choices, verbose_name="建筑类型", help_text="slab=板楼 / tower=塔楼 / slab_tower=板塔结合", ) land_use_years = models.CharField( max_length=30, blank=True, default="", verbose_name="土地使用年限", help_text='如「70年」', ) built_year = models.SmallIntegerField( null=True, blank=True, verbose_name="竣工年份", help_text="可多选时存最早竣工年", ) built_years = ArrayField( models.SmallIntegerField(), default=list, blank=True, verbose_name="竣工年份多值", help_text="楼盘分期竣工", ) ownership_category = ArrayField( models.CharField(max_length=30), default=list, blank=True, verbose_name="权属类别", help_text="多选(运营维护枚举)", ) total_units = models.IntegerField( null=True, blank=True, verbose_name="单元总数", ) total_households = models.IntegerField( null=True, blank=True, verbose_name="总户数", ) total_floor_area = models.DecimalField( max_digits=12, decimal_places=2, null=True, blank=True, verbose_name="小区总建筑面积", help_text="单位:m²", ) plot_area = models.DecimalField( max_digits=12, decimal_places=2, null=True, blank=True, verbose_name="小区占地面积", help_text="单位:m²", ) plot_ratio = models.DecimalField( max_digits=5, decimal_places=2, null=True, blank=True, verbose_name="容积率", ) green_rate = models.DecimalField( max_digits=5, decimal_places=2, null=True, blank=True, verbose_name="绿化率", help_text="单位:%", ) developer = models.CharField( max_length=200, blank=True, default="", verbose_name="开发商", ) property_company = models.CharField( max_length=200, blank=True, default="", verbose_name="物业公司", ) property_fee = models.DecimalField( max_digits=8, decimal_places=2, null=True, blank=True, verbose_name="物业费", help_text="单位:元/m²/月", ) property_phone = models.CharField( max_length=30, blank=True, default="", verbose_name="物业电话", ) parking_total = models.IntegerField( null=True, blank=True, verbose_name="车位总数", ) parking_underground = models.IntegerField( null=True, blank=True, verbose_name="地下车位数", ) parking_ratio = models.CharField( max_length=20, blank=True, default="", verbose_name="车位配比", help_text='如「100:63」', ) water_type = models.CharField( max_length=10, blank=True, default="", choices=ComplexWaterType.choices, verbose_name="水费类型", help_text="civil=民水 / commercial=商水", ) electricity_type = models.CharField( max_length=10, blank=True, default="", choices=ComplexElectricityType.choices, verbose_name="电费类型", help_text="civil=民电 / commercial=商电", ) has_central_heating = models.BooleanField( null=True, blank=True, verbose_name="是否统一供暖", ) has_gas = models.BooleanField( null=True, blank=True, verbose_name="是否有燃气", ) remarks = models.TextField( blank=True, default="", verbose_name="备注", ) lock_building = models.BooleanField( default=False, verbose_name="楼栋锁", help_text="锁定后不可增删楼栋", ) lock_room = models.BooleanField( default=False, verbose_name="房号锁", ) lock_info = models.BooleanField( default=False, verbose_name="信息锁", help_text="锁定后基本信息只读", ) lock_standard_room = models.BooleanField( default=False, verbose_name="标准房号锁", ) search_vector = SearchVectorField( null=True, blank=True, verbose_name="全文检索向量", help_text="由触发器自动维护(name + alias + address)", ) is_active = models.BooleanField( default=True, verbose_name="是否启用", help_text="FALSE=已停用楼盘", ) created_by = models.ForeignKey( "org.Staff", null=True, blank=True, on_delete=models.SET_NULL, related_name="created_complexes", verbose_name="创建人", ) updated_by = models.ForeignKey( "org.Staff", null=True, blank=True, on_delete=models.SET_NULL, related_name="updated_complexes", verbose_name="最后更新人", ) version = models.IntegerField( default=1, verbose_name="版本号", help_text="乐观锁;UPDATE 时 +1;应用层检测 0 行受影响时抛 ConflictError", ) business_areas = models.ManyToManyField( "region.BusinessArea", through="fonrey_complex.ComplexBusinessArea", related_name="complexes", verbose_name="关联商圈", ) schools = models.ManyToManyField( "region.School", through="fonrey_complex.ComplexSchool", related_name="complexes", verbose_name="对口学校", ) metro_stations = models.ManyToManyField( "region.MetroStation", through="fonrey_complex.ComplexMetroStation", related_name="complexes", verbose_name="周边地铁站", ) class Meta: db_table = "complexes" verbose_name = "楼盘" verbose_name_plural = "楼盘" indexes = [ models.Index( fields=["district"], name="idx_complexes_district", condition=models.Q(deleted_at__isnull=True), ), GinIndex(fields=["search_vector"], name="idx_complexes_search"), models.Index( fields=["latitude", "longitude"], name="idx_complexes_geo", condition=models.Q(deleted_at__isnull=True, latitude__isnull=False), ), models.Index( fields=["is_active"], name="idx_complexes_active", condition=models.Q(deleted_at__isnull=True), ), ] ordering = ["name"] def __str__(self) -> str: return self.name class ComplexAlias(UUIDPrimaryKeyModel): complex = models.ForeignKey( "fonrey_complex.Complex", on_delete=models.CASCADE, related_name="aliases", verbose_name="所属楼盘", help_text="别名随楼盘级联删除", ) alias = models.CharField( max_length=200, verbose_name="别名", help_text="最多20字/条,多别名多行存储", ) is_system = models.BooleanField( default=False, verbose_name="是否系统别名", help_text="TRUE=系统/标准别名(只读),FALSE=用户自定义", ) created_at = models.DateTimeField( auto_now_add=True, verbose_name="创建时间", ) created_by = models.ForeignKey( "org.Staff", null=True, blank=True, on_delete=models.SET_NULL, related_name="created_complex_aliases", verbose_name="创建人", ) class Meta: db_table = "complex_aliases" verbose_name = "楼盘别名" verbose_name_plural = "楼盘别名" indexes = [ models.Index(fields=["complex"], name="idx_complex_aliases_complex"), ] ordering = ["complex_id", "alias"] def __str__(self) -> str: return self.alias class ComplexBusinessArea(models.Model): complex = models.ForeignKey( "fonrey_complex.Complex", on_delete=models.CASCADE, related_name="complex_business_areas", verbose_name="所属楼盘", ) business_area = models.ForeignKey( "region.BusinessArea", on_delete=models.CASCADE, related_name="complex_links", verbose_name="关联商圈", ) is_primary = models.BooleanField( default=False, verbose_name="是否主商圈", help_text="主商圈唯一,用于列表显示", ) class Meta: db_table = "complex_business_areas" verbose_name = "楼盘商圈关联" verbose_name_plural = "楼盘商圈关联" constraints = [ models.UniqueConstraint( fields=["complex", "business_area"], name="pk_complex_business_areas", ), models.UniqueConstraint( fields=["complex"], condition=models.Q(is_primary=True), name="uq_complex_biz_area_primary", ), ] class ComplexSchool(models.Model): complex = models.ForeignKey( "fonrey_complex.Complex", on_delete=models.CASCADE, related_name="complex_schools", verbose_name="所属楼盘", ) school = models.ForeignKey( "region.School", on_delete=models.CASCADE, related_name="complex_links", verbose_name="对口学校", ) zone_type = models.CharField( max_length=30, blank=True, default="", choices=SchoolZoneType.choices, verbose_name="学区类型", help_text="guaranteed=对口(直升) / reference=参考(可能入读) / lottery=摇号", ) class Meta: db_table = "complex_schools" verbose_name = "楼盘学校关联" verbose_name_plural = "楼盘学校关联" constraints = [ models.UniqueConstraint( fields=["complex", "school"], name="pk_complex_schools", ), ] indexes = [ models.Index(fields=["school"], name="idx_complex_schools_school"), ] class ComplexMetroStation(models.Model): complex = models.ForeignKey( "fonrey_complex.Complex", on_delete=models.CASCADE, related_name="complex_metro_stations", verbose_name="所属楼盘", ) station = models.ForeignKey( "region.MetroStation", on_delete=models.CASCADE, related_name="complex_links", verbose_name="关联地铁站", ) distance_meters = models.IntegerField( null=True, blank=True, verbose_name="步行距离", help_text="单位:米", ) class Meta: db_table = "complex_metro_stations" verbose_name = "楼盘地铁站关联" verbose_name_plural = "楼盘地铁站关联" constraints = [ models.UniqueConstraint( fields=["complex", "station"], name="pk_complex_metro_stations", ), ] indexes = [ models.Index(fields=["complex"], name="idx_complex_metro_complex"), models.Index(fields=["station"], name="idx_complex_metro_station"), ] class Building(TimeStampedModel): complex = models.ForeignKey( "fonrey_complex.Complex", on_delete=models.CASCADE, related_name="buildings", verbose_name="所属楼盘", ) name = models.CharField( max_length=50, verbose_name="楼栋名称", help_text='如「1号楼」「A栋2单元」', ) is_standard = models.BooleanField( default=False, verbose_name="是否标准结构", help_text="TRUE=已经运营核准", ) property_usage_type = models.CharField( max_length=30, blank=True, default="", choices=ComplexPropertyUsageType.choices, verbose_name="物业类型", help_text="可与楼盘不同,如商住楼盘内有纯商铺楼栋", ) built_year = models.SmallIntegerField( null=True, blank=True, verbose_name="竣工年份", ) total_floors = models.SmallIntegerField( null=True, blank=True, verbose_name="总层数", ) land_use_years = models.CharField( max_length=30, blank=True, default="", verbose_name="土地使用年限", ) has_elevator = models.BooleanField( null=True, blank=True, verbose_name="是否有电梯", ) school = models.ForeignKey( "region.School", null=True, blank=True, on_delete=models.SET_NULL, related_name="buildings", verbose_name="对口学校", help_text="楼栋级别的学区差异", ) is_active = models.BooleanField( default=True, verbose_name="是否启用", help_text="FALSE=已停用(楼栋被删除或合并)", ) created_by = models.ForeignKey( "org.Staff", null=True, blank=True, on_delete=models.SET_NULL, related_name="created_buildings", verbose_name="创建人", ) class Meta: db_table = "buildings" verbose_name = "楼栋" verbose_name_plural = "楼栋" indexes = [ models.Index( fields=["complex"], name="idx_buildings_complex", condition=models.Q(is_active=True), ), ] constraints = [ models.UniqueConstraint( fields=["complex", "name"], condition=models.Q(is_active=True), name="uq_buildings_complex_name", ), ] ordering = ["complex_id", "name"] def __str__(self) -> str: return self.name class RoomUnit(TimeStampedModel): building = models.ForeignKey( "fonrey_complex.Building", on_delete=models.CASCADE, related_name="room_units", verbose_name="所属楼栋", ) floor = models.SmallIntegerField( verbose_name="楼层", help_text="实际层数,地下为负数", ) floor_name = models.CharField( max_length=20, blank=True, default="", verbose_name="楼层名称", help_text='如「1层」「B1层」', ) room_no = models.CharField( max_length=30, verbose_name="房号", help_text='如「01」「101」', ) display_no = models.CharField( max_length=50, blank=True, default="", verbose_name="展示房号", help_text='展示用完整房号,如「3-1-101」', ) is_standard = models.BooleanField( default=False, verbose_name="是否标准化", help_text="TRUE=已归一化为标准结构", ) is_active = models.BooleanField( default=True, verbose_name="是否启用", help_text="FALSE=已拆除/不存在", ) class Meta: db_table = "room_units" verbose_name = "房号单元" verbose_name_plural = "房号单元" indexes = [ models.Index( fields=["building"], name="idx_room_units_building", condition=models.Q(is_active=True), ), ] constraints = [ models.UniqueConstraint( fields=["building", "floor", "room_no"], condition=models.Q(is_active=True), name="uq_room_units_unique", ), ] ordering = ["building_id", "-floor", "room_no"] def __str__(self) -> str: return self.display_no or f"{self.floor}/{self.room_no}" class ComplexPhoto(UUIDPrimaryKeyModel): complex = models.ForeignKey( "fonrey_complex.Complex", on_delete=models.CASCADE, related_name="photos", verbose_name="所属楼盘", ) category = models.CharField( max_length=20, choices=ComplexPhotoCategory.choices, verbose_name="照片类别", help_text="complex=楼盘图 / layout=户型图 / vr=VR全景 / other=其他", ) file_key = models.TextField( verbose_name="文件存储路径", help_text="R2/S3 路径", ) thumbnail_key = models.TextField( blank=True, default="", verbose_name="缩略图路径", ) file_name = models.CharField( max_length=255, blank=True, default="", verbose_name="原始文件名", ) file_size = models.IntegerField( null=True, blank=True, verbose_name="文件大小", help_text="单位:bytes", ) width = models.IntegerField( null=True, blank=True, verbose_name="图片宽度", help_text="单位:px", ) height = models.IntegerField( null=True, blank=True, verbose_name="图片高度", help_text="单位:px", ) is_cover = models.BooleanField( default=False, verbose_name="是否封面图", help_text="楼盘封面图(每楼盘唯一)", ) sort_order = models.SmallIntegerField( default=0, verbose_name="排序顺序", help_text="同类别内的排序顺序", ) created_at = models.DateTimeField( auto_now_add=True, verbose_name="创建时间", ) created_by = models.ForeignKey( "org.Staff", null=True, blank=True, on_delete=models.SET_NULL, related_name="created_complex_photos", verbose_name="上传人", ) class Meta: db_table = "complex_photos" verbose_name = "楼盘照片" verbose_name_plural = "楼盘照片" indexes = [ models.Index(fields=["complex"], name="idx_complex_photos_complex"), models.Index(fields=["complex", "category"], name="idx_complex_photos_category"), ] constraints = [ models.UniqueConstraint( fields=["complex"], condition=models.Q(is_cover=True), name="uq_complex_photos_cover", ), ] ordering = ["complex_id", "sort_order"] class ComplexAttachment(UUIDPrimaryKeyModel): complex = models.ForeignKey( "fonrey_complex.Complex", on_delete=models.CASCADE, related_name="attachments", verbose_name="所属楼盘", ) file_key = models.TextField( verbose_name="文件存储路径", help_text="R2/S3 存储路径", ) file_name = models.CharField( max_length=255, verbose_name="原始文件名", ) file_size = models.IntegerField( null=True, blank=True, verbose_name="文件大小", help_text="单位:bytes", ) file_type = models.CharField( max_length=50, blank=True, default="", verbose_name="文件类型", help_text="MIME type", ) sort_order = models.SmallIntegerField( default=0, verbose_name="排序顺序", ) created_at = models.DateTimeField( auto_now_add=True, verbose_name="创建时间", ) created_by = models.ForeignKey( "org.Staff", null=True, blank=True, on_delete=models.SET_NULL, related_name="created_complex_attachments", verbose_name="上传人", ) class Meta: db_table = "complex_attachments" verbose_name = "楼盘附件" verbose_name_plural = "楼盘附件" ordering = ["complex_id", "sort_order"] class ComplexPriceTrend(UUIDPrimaryKeyModel): complex = models.ForeignKey( "fonrey_complex.Complex", on_delete=models.CASCADE, related_name="price_trends", verbose_name="所属楼盘", ) record_month = models.DateField( verbose_name="月份", help_text="统一存为该月1日,如 2026-04-01", ) avg_sale_price = models.DecimalField( max_digits=12, decimal_places=2, null=True, blank=True, verbose_name="月均售价", help_text="单位:万元/套", ) avg_unit_price = models.DecimalField( max_digits=10, decimal_places=2, null=True, blank=True, verbose_name="月均单价", help_text="单位:元/m²", ) transaction_count = models.IntegerField( null=True, blank=True, verbose_name="成交套数", ) listing_count = models.IntegerField( null=True, blank=True, verbose_name="当月挂牌套数", ) created_at = models.DateTimeField( auto_now_add=True, verbose_name="创建时间", ) class Meta: db_table = "complex_price_trends" verbose_name = "楼盘价格走势" verbose_name_plural = "楼盘价格走势" constraints = [ models.UniqueConstraint( fields=["complex", "record_month"], name="uq_complex_price_trend_month", ), ] indexes = [ models.Index( fields=["complex", "-record_month"], name="idx_cpx_price_trend_complex", ), ] ordering = ["complex_id", "-record_month"]