feat(complex): add apps.complex with 10 models and full-text search

- 10 models: Complex, ComplexAlias, ComplexBusinessArea, ComplexSchool, ComplexMetroStation, Building, RoomUnit, ComplexPhoto, ComplexAttachment, ComplexPriceTrend
- RunSQL migration: pg_trgm extension, gin_trgm_ops indexes, tsvector triggers for complex search_vector (from name/alias/address)
- Optimistic locking via version field on Complex
- 4 lock flags (lock_building/room/info/standard_room) per spec
- Adds ComplexPropertyUsageType + ComplexBuildingStructure enums
- manage.py check passes

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-04-29 17:19:01 +08:00
parent 9a7d06b34e
commit c57462f6d1
12 changed files with 909 additions and 0 deletions

View File

@@ -0,0 +1,457 @@
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, help_text="标准楼盘名称,不可在编辑页直接修改")
district = models.ForeignKey(
"region.District",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="complexes",
)
address = models.CharField(max_length=500, blank=True, default="")
address_summary = models.CharField(max_length=100, blank=True, default="")
latitude = models.DecimalField(max_digits=10, decimal_places=7, null=True, blank=True)
longitude = models.DecimalField(max_digits=10, decimal_places=7, null=True, blank=True)
property_usage_types = ArrayField(
models.CharField(max_length=30, choices=ComplexPropertyUsageType.choices),
default=list,
blank=True,
)
building_structure = models.CharField(
max_length=30,
blank=True,
default="",
choices=ComplexBuildingStructure.choices,
)
building_type = models.CharField(
max_length=20,
blank=True,
default="",
choices=ComplexBuildingType.choices,
)
land_use_years = models.CharField(max_length=30, blank=True, default="")
built_year = models.SmallIntegerField(null=True, blank=True)
built_years = ArrayField(
models.SmallIntegerField(),
default=list,
blank=True,
)
ownership_category = ArrayField(
models.CharField(max_length=30),
default=list,
blank=True,
)
total_units = models.IntegerField(null=True, blank=True)
total_households = models.IntegerField(null=True, blank=True)
total_floor_area = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
plot_area = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
plot_ratio = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)
green_rate = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)
developer = models.CharField(max_length=200, blank=True, default="")
property_company = models.CharField(max_length=200, blank=True, default="")
property_fee = models.DecimalField(max_digits=8, decimal_places=2, null=True, blank=True)
property_phone = models.CharField(max_length=30, blank=True, default="")
parking_total = models.IntegerField(null=True, blank=True)
parking_underground = models.IntegerField(null=True, blank=True)
parking_ratio = models.CharField(max_length=20, blank=True, default="")
water_type = models.CharField(
max_length=10,
blank=True,
default="",
choices=ComplexWaterType.choices,
)
electricity_type = models.CharField(
max_length=10,
blank=True,
default="",
choices=ComplexElectricityType.choices,
)
has_central_heating = models.BooleanField(null=True, blank=True)
has_gas = models.BooleanField(null=True, blank=True)
remarks = models.TextField(blank=True, default="")
lock_building = models.BooleanField(default=False)
lock_room = models.BooleanField(default=False)
lock_info = models.BooleanField(default=False)
lock_standard_room = models.BooleanField(default=False)
search_vector = SearchVectorField(null=True, blank=True)
is_active = models.BooleanField(default=True)
created_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_complexes",
)
updated_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="updated_complexes",
)
version = models.IntegerField(default=1, help_text="乐观锁版本号UPDATE 时 +1")
business_areas = models.ManyToManyField(
"region.BusinessArea",
through="fonrey_complex.ComplexBusinessArea",
related_name="complexes",
)
schools = models.ManyToManyField(
"region.School",
through="fonrey_complex.ComplexSchool",
related_name="complexes",
)
metro_stations = models.ManyToManyField(
"region.MetroStation",
through="fonrey_complex.ComplexMetroStation",
related_name="complexes",
)
class Meta:
db_table = "complexes"
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",
)
alias = models.CharField(max_length=200)
is_system = models.BooleanField(default=False, help_text="TRUE=系统/标准别名(只读)")
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_complex_aliases",
)
class Meta:
db_table = "complex_aliases"
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",
)
business_area = models.ForeignKey(
"region.BusinessArea",
on_delete=models.CASCADE,
related_name="complex_links",
)
is_primary = models.BooleanField(default=False)
class Meta:
db_table = "complex_business_areas"
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",
)
school = models.ForeignKey(
"region.School",
on_delete=models.CASCADE,
related_name="complex_links",
)
zone_type = models.CharField(
max_length=30,
blank=True,
default="",
choices=SchoolZoneType.choices,
)
class Meta:
db_table = "complex_schools"
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",
)
station = models.ForeignKey(
"region.MetroStation",
on_delete=models.CASCADE,
related_name="complex_links",
)
distance_meters = models.IntegerField(null=True, blank=True)
class Meta:
db_table = "complex_metro_stations"
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",
)
name = models.CharField(max_length=50, help_text="楼栋名如「1号楼」「A栋2单元」")
is_standard = models.BooleanField(default=False, help_text="TRUE=标准结构(经运营核准)")
property_usage_type = models.CharField(
max_length=30,
blank=True,
default="",
choices=ComplexPropertyUsageType.choices,
)
built_year = models.SmallIntegerField(null=True, blank=True)
total_floors = models.SmallIntegerField(null=True, blank=True)
land_use_years = models.CharField(max_length=30, blank=True, default="")
has_elevator = models.BooleanField(null=True, blank=True)
school = models.ForeignKey(
"region.School",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="buildings",
)
is_active = models.BooleanField(default=True)
created_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_buildings",
)
class Meta:
db_table = "buildings"
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",
)
floor = models.SmallIntegerField(help_text="楼层(实际层数,地下为负数)")
floor_name = models.CharField(max_length=20, blank=True, default="")
room_no = models.CharField(max_length=30)
display_no = models.CharField(max_length=50, blank=True, default="")
is_standard = models.BooleanField(default=False)
is_active = models.BooleanField(default=True)
class Meta:
db_table = "room_units"
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",
)
category = models.CharField(max_length=20, choices=ComplexPhotoCategory.choices)
file_key = models.TextField()
thumbnail_key = models.TextField(blank=True, default="")
file_name = models.CharField(max_length=255, blank=True, default="")
file_size = models.IntegerField(null=True, blank=True, help_text="bytes")
width = models.IntegerField(null=True, blank=True)
height = models.IntegerField(null=True, blank=True)
is_cover = models.BooleanField(default=False)
sort_order = models.SmallIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_complex_photos",
)
class Meta:
db_table = "complex_photos"
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",
)
file_key = models.TextField()
file_name = models.CharField(max_length=255)
file_size = models.IntegerField(null=True, blank=True)
file_type = models.CharField(max_length=50, blank=True, default="", help_text="MIME type")
sort_order = models.SmallIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_complex_attachments",
)
class Meta:
db_table = "complex_attachments"
ordering = ["complex_id", "sort_order"]
class ComplexPriceTrend(UUIDPrimaryKeyModel):
complex = models.ForeignKey(
"fonrey_complex.Complex",
on_delete=models.CASCADE,
related_name="price_trends",
)
record_month = models.DateField(help_text="月份统一存为该月1日")
avg_sale_price = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
avg_unit_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
transaction_count = models.IntegerField(null=True, blank=True)
listing_count = models.IntegerField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "complex_price_trends"
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"]