diff --git a/apps/complex/migrations/0001_initial.py b/apps/complex/migrations/0001_initial.py new file mode 100644 index 0000000..064045c --- /dev/null +++ b/apps/complex/migrations/0001_initial.py @@ -0,0 +1,332 @@ +# Generated by Django 4.2.16 on 2026-04-29 09:12 + +import django.contrib.postgres.fields +import django.contrib.postgres.indexes +import django.contrib.postgres.search +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('region', '0001_initial'), + ('org', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Complex', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('deleted_at', models.DateTimeField(blank=True, db_index=True, null=True)), + ('name', models.CharField(help_text='标准楼盘名称,不可在编辑页直接修改', max_length=200)), + ('address', models.CharField(blank=True, default='', max_length=500)), + ('address_summary', models.CharField(blank=True, default='', max_length=100)), + ('latitude', models.DecimalField(blank=True, decimal_places=7, max_digits=10, null=True)), + ('longitude', models.DecimalField(blank=True, decimal_places=7, max_digits=10, null=True)), + ('property_usage_types', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('residential', '住宅'), ('villa', '别墅'), ('commercial_residential', '商住'), ('commercial', '商业'), ('office', '写字楼'), ('other', '其他')], max_length=30), blank=True, default=list, size=None)), + ('building_structure', models.CharField(blank=True, choices=[('unit_room', '单元-房号'), ('other', '其他')], default='', max_length=30)), + ('building_type', models.CharField(blank=True, choices=[('slab', '板楼'), ('tower', '塔楼'), ('slab_tower', '板塔结合')], default='', max_length=20)), + ('land_use_years', models.CharField(blank=True, default='', max_length=30)), + ('built_year', models.SmallIntegerField(blank=True, null=True)), + ('built_years', django.contrib.postgres.fields.ArrayField(base_field=models.SmallIntegerField(), blank=True, default=list, size=None)), + ('ownership_category', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=30), blank=True, default=list, size=None)), + ('total_units', models.IntegerField(blank=True, null=True)), + ('total_households', models.IntegerField(blank=True, null=True)), + ('total_floor_area', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)), + ('plot_area', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)), + ('plot_ratio', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True)), + ('green_rate', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True)), + ('developer', models.CharField(blank=True, default='', max_length=200)), + ('property_company', models.CharField(blank=True, default='', max_length=200)), + ('property_fee', models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True)), + ('property_phone', models.CharField(blank=True, default='', max_length=30)), + ('parking_total', models.IntegerField(blank=True, null=True)), + ('parking_underground', models.IntegerField(blank=True, null=True)), + ('parking_ratio', models.CharField(blank=True, default='', max_length=20)), + ('water_type', models.CharField(blank=True, choices=[('civil', '民水'), ('commercial', '商水')], default='', max_length=10)), + ('electricity_type', models.CharField(blank=True, choices=[('civil', '民电'), ('commercial', '商电')], default='', max_length=10)), + ('has_central_heating', models.BooleanField(blank=True, null=True)), + ('has_gas', models.BooleanField(blank=True, null=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', django.contrib.postgres.search.SearchVectorField(blank=True, null=True)), + ('is_active', models.BooleanField(default=True)), + ('version', models.IntegerField(default=1, help_text='乐观锁版本号;UPDATE 时 +1')), + ], + options={ + 'db_table': 'complexes', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='ComplexSchool', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('zone_type', models.CharField(blank=True, choices=[('guaranteed', '对口'), ('reference', '参考'), ('lottery', '摇号')], default='', max_length=30)), + ('complex', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complex_schools', to='fonrey_complex.complex')), + ('school', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complex_links', to='region.school')), + ], + options={ + 'db_table': 'complex_schools', + }, + ), + migrations.CreateModel( + name='ComplexPriceTrend', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('record_month', models.DateField(help_text='月份(统一存为该月1日)')), + ('avg_sale_price', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)), + ('avg_unit_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), + ('transaction_count', models.IntegerField(blank=True, null=True)), + ('listing_count', models.IntegerField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('complex', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='price_trends', to='fonrey_complex.complex')), + ], + options={ + 'db_table': 'complex_price_trends', + 'ordering': ['complex_id', '-record_month'], + }, + ), + migrations.CreateModel( + name='ComplexPhoto', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('category', models.CharField(choices=[('complex', '楼盘图'), ('layout', '户型图'), ('vr', 'VR图'), ('other', '其他')], max_length=20)), + ('file_key', models.TextField()), + ('thumbnail_key', models.TextField(blank=True, default='')), + ('file_name', models.CharField(blank=True, default='', max_length=255)), + ('file_size', models.IntegerField(blank=True, help_text='bytes', null=True)), + ('width', models.IntegerField(blank=True, null=True)), + ('height', models.IntegerField(blank=True, null=True)), + ('is_cover', models.BooleanField(default=False)), + ('sort_order', models.SmallIntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('complex', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='fonrey_complex.complex')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_complex_photos', to='org.staff')), + ], + options={ + 'db_table': 'complex_photos', + 'ordering': ['complex_id', 'sort_order'], + }, + ), + migrations.CreateModel( + name='ComplexMetroStation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('distance_meters', models.IntegerField(blank=True, null=True)), + ('complex', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complex_metro_stations', to='fonrey_complex.complex')), + ('station', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complex_links', to='region.metrostation')), + ], + options={ + 'db_table': 'complex_metro_stations', + }, + ), + migrations.CreateModel( + name='ComplexBusinessArea', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_primary', models.BooleanField(default=False)), + ('business_area', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complex_links', to='region.businessarea')), + ('complex', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complex_business_areas', to='fonrey_complex.complex')), + ], + options={ + 'db_table': 'complex_business_areas', + }, + ), + migrations.CreateModel( + name='ComplexAttachment', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('file_key', models.TextField()), + ('file_name', models.CharField(max_length=255)), + ('file_size', models.IntegerField(blank=True, null=True)), + ('file_type', models.CharField(blank=True, default='', help_text='MIME type', max_length=50)), + ('sort_order', models.SmallIntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('complex', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='fonrey_complex.complex')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_complex_attachments', to='org.staff')), + ], + options={ + 'db_table': 'complex_attachments', + 'ordering': ['complex_id', 'sort_order'], + }, + ), + migrations.CreateModel( + name='ComplexAlias', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('alias', models.CharField(max_length=200)), + ('is_system', models.BooleanField(default=False, help_text='TRUE=系统/标准别名(只读)')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('complex', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='aliases', to='fonrey_complex.complex')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_complex_aliases', to='org.staff')), + ], + options={ + 'db_table': 'complex_aliases', + 'ordering': ['complex_id', 'alias'], + }, + ), + migrations.AddField( + model_name='complex', + name='business_areas', + field=models.ManyToManyField(related_name='complexes', through='fonrey_complex.ComplexBusinessArea', to='region.businessarea'), + ), + migrations.AddField( + model_name='complex', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_complexes', to='org.staff'), + ), + migrations.AddField( + model_name='complex', + name='district', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complexes', to='region.district'), + ), + migrations.AddField( + model_name='complex', + name='metro_stations', + field=models.ManyToManyField(related_name='complexes', through='fonrey_complex.ComplexMetroStation', to='region.metrostation'), + ), + migrations.AddField( + model_name='complex', + name='schools', + field=models.ManyToManyField(related_name='complexes', through='fonrey_complex.ComplexSchool', to='region.school'), + ), + migrations.AddField( + model_name='complex', + name='updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_complexes', to='org.staff'), + ), + migrations.CreateModel( + name='Building', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(help_text='楼栋名,如「1号楼」「A栋2单元」', max_length=50)), + ('is_standard', models.BooleanField(default=False, help_text='TRUE=标准结构(经运营核准)')), + ('property_usage_type', models.CharField(blank=True, choices=[('residential', '住宅'), ('villa', '别墅'), ('commercial_residential', '商住'), ('commercial', '商业'), ('office', '写字楼'), ('other', '其他')], default='', max_length=30)), + ('built_year', models.SmallIntegerField(blank=True, null=True)), + ('total_floors', models.SmallIntegerField(blank=True, null=True)), + ('land_use_years', models.CharField(blank=True, default='', max_length=30)), + ('has_elevator', models.BooleanField(blank=True, null=True)), + ('is_active', models.BooleanField(default=True)), + ('complex', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='buildings', to='fonrey_complex.complex')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_buildings', to='org.staff')), + ('school', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='buildings', to='region.school')), + ], + options={ + 'db_table': 'buildings', + 'ordering': ['complex_id', 'name'], + }, + ), + migrations.CreateModel( + name='RoomUnit', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('floor', models.SmallIntegerField(help_text='楼层(实际层数,地下为负数)')), + ('floor_name', models.CharField(blank=True, default='', max_length=20)), + ('room_no', models.CharField(max_length=30)), + ('display_no', models.CharField(blank=True, default='', max_length=50)), + ('is_standard', models.BooleanField(default=False)), + ('is_active', models.BooleanField(default=True)), + ('building', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='room_units', to='fonrey_complex.building')), + ], + options={ + 'db_table': 'room_units', + 'ordering': ['building_id', '-floor', 'room_no'], + 'indexes': [models.Index(condition=models.Q(('is_active', True)), fields=['building'], name='idx_room_units_building')], + }, + ), + migrations.AddConstraint( + model_name='roomunit', + constraint=models.UniqueConstraint(condition=models.Q(('is_active', True)), fields=('building', 'floor', 'room_no'), name='uq_room_units_unique'), + ), + migrations.AddIndex( + model_name='complexschool', + index=models.Index(fields=['school'], name='idx_complex_schools_school'), + ), + migrations.AddConstraint( + model_name='complexschool', + constraint=models.UniqueConstraint(fields=('complex', 'school'), name='pk_complex_schools'), + ), + migrations.AddIndex( + model_name='complexpricetrend', + index=models.Index(fields=['complex', '-record_month'], name='idx_cpx_price_trend_complex'), + ), + migrations.AddConstraint( + model_name='complexpricetrend', + constraint=models.UniqueConstraint(fields=('complex', 'record_month'), name='uq_complex_price_trend_month'), + ), + migrations.AddIndex( + model_name='complexphoto', + index=models.Index(fields=['complex'], name='idx_complex_photos_complex'), + ), + migrations.AddIndex( + model_name='complexphoto', + index=models.Index(fields=['complex', 'category'], name='idx_complex_photos_category'), + ), + migrations.AddConstraint( + model_name='complexphoto', + constraint=models.UniqueConstraint(condition=models.Q(('is_cover', True)), fields=('complex',), name='uq_complex_photos_cover'), + ), + migrations.AddIndex( + model_name='complexmetrostation', + index=models.Index(fields=['complex'], name='idx_complex_metro_complex'), + ), + migrations.AddIndex( + model_name='complexmetrostation', + index=models.Index(fields=['station'], name='idx_complex_metro_station'), + ), + migrations.AddConstraint( + model_name='complexmetrostation', + constraint=models.UniqueConstraint(fields=('complex', 'station'), name='pk_complex_metro_stations'), + ), + migrations.AddConstraint( + model_name='complexbusinessarea', + constraint=models.UniqueConstraint(fields=('complex', 'business_area'), name='pk_complex_business_areas'), + ), + migrations.AddConstraint( + model_name='complexbusinessarea', + constraint=models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('complex',), name='uq_complex_biz_area_primary'), + ), + migrations.AddIndex( + model_name='complexalias', + index=models.Index(fields=['complex'], name='idx_complex_aliases_complex'), + ), + migrations.AddIndex( + model_name='complex', + index=models.Index(condition=models.Q(('deleted_at__isnull', True)), fields=['district'], name='idx_complexes_district'), + ), + migrations.AddIndex( + model_name='complex', + index=django.contrib.postgres.indexes.GinIndex(fields=['search_vector'], name='idx_complexes_search'), + ), + migrations.AddIndex( + model_name='complex', + index=models.Index(condition=models.Q(('deleted_at__isnull', True), ('latitude__isnull', False)), fields=['latitude', 'longitude'], name='idx_complexes_geo'), + ), + migrations.AddIndex( + model_name='complex', + index=models.Index(condition=models.Q(('deleted_at__isnull', True)), fields=['is_active'], name='idx_complexes_active'), + ), + migrations.AddIndex( + model_name='building', + index=models.Index(condition=models.Q(('is_active', True)), fields=['complex'], name='idx_buildings_complex'), + ), + migrations.AddConstraint( + model_name='building', + constraint=models.UniqueConstraint(condition=models.Q(('is_active', True)), fields=('complex', 'name'), name='uq_buildings_complex_name'), + ), + ] diff --git a/apps/complex/migrations/0002_pg_trgm_and_search_vector.py b/apps/complex/migrations/0002_pg_trgm_and_search_vector.py new file mode 100644 index 0000000..6a676f0 --- /dev/null +++ b/apps/complex/migrations/0002_pg_trgm_and_search_vector.py @@ -0,0 +1,76 @@ +from django.db import migrations + + +SQL_FORWARDS = r""" +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE INDEX IF NOT EXISTS idx_complexes_name_trgm + ON complexes USING gin (name gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS idx_complex_aliases_alias_trgm + ON complex_aliases USING gin (alias gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS idx_schools_name_trgm + ON schools USING gin (name gin_trgm_ops); + +CREATE OR REPLACE FUNCTION update_complex_search_vector() +RETURNS TRIGGER AS $$ +BEGIN + NEW.search_vector := + setweight(to_tsvector('simple', COALESCE(NEW.name, '')), 'A') || + setweight(to_tsvector('simple', COALESCE(NEW.address_summary, '')), 'B') || + setweight(to_tsvector('simple', COALESCE(NEW.address, '')), 'C'); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_complex_search_vector ON complexes; +CREATE TRIGGER trg_complex_search_vector + BEFORE INSERT OR UPDATE OF name, address_summary, address + ON complexes + FOR EACH ROW EXECUTE FUNCTION update_complex_search_vector(); + +CREATE OR REPLACE FUNCTION update_complex_search_on_alias() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE complexes + SET search_vector = ( + setweight(to_tsvector('simple', COALESCE(name, '')), 'A') || + setweight(to_tsvector('simple', + COALESCE((SELECT string_agg(alias, ' ') FROM complex_aliases WHERE complex_id = complexes.id), '')), 'B') || + setweight(to_tsvector('simple', COALESCE(address_summary, '')), 'C') || + setweight(to_tsvector('simple', COALESCE(address, '')), 'D') + ), + updated_at = NOW() + WHERE id = COALESCE(NEW.complex_id, OLD.complex_id); + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_complex_alias_search ON complex_aliases; +CREATE TRIGGER trg_complex_alias_search + AFTER INSERT OR UPDATE OR DELETE ON complex_aliases + FOR EACH ROW EXECUTE FUNCTION update_complex_search_on_alias(); +""" + +SQL_REVERSE = r""" +DROP TRIGGER IF EXISTS trg_complex_alias_search ON complex_aliases; +DROP TRIGGER IF EXISTS trg_complex_search_vector ON complexes; +DROP FUNCTION IF EXISTS update_complex_search_on_alias(); +DROP FUNCTION IF EXISTS update_complex_search_vector(); +DROP INDEX IF EXISTS idx_schools_name_trgm; +DROP INDEX IF EXISTS idx_complex_aliases_alias_trgm; +DROP INDEX IF EXISTS idx_complexes_name_trgm; +""" + + +class Migration(migrations.Migration): + + dependencies = [ + ("fonrey_complex", "0001_initial"), + ("region", "0001_initial"), + ] + + operations = [ + migrations.RunSQL(sql=SQL_FORWARDS, reverse_sql=SQL_REVERSE), + ] diff --git a/apps/complex/models/__init__.py b/apps/complex/models/__init__.py index e69de29..a72a661 100644 --- a/apps/complex/models/__init__.py +++ b/apps/complex/models/__init__.py @@ -0,0 +1,25 @@ +from apps.complex.models.complex import ( + Building, + Complex, + ComplexAlias, + ComplexAttachment, + ComplexBusinessArea, + ComplexMetroStation, + ComplexPhoto, + ComplexPriceTrend, + ComplexSchool, + RoomUnit, +) + +__all__ = [ + "Building", + "Complex", + "ComplexAlias", + "ComplexAttachment", + "ComplexBusinessArea", + "ComplexMetroStation", + "ComplexPhoto", + "ComplexPriceTrend", + "ComplexSchool", + "RoomUnit", +] diff --git a/apps/complex/models/complex.py b/apps/complex/models/complex.py new file mode 100644 index 0000000..58a9ea6 --- /dev/null +++ b/apps/complex/models/complex.py @@ -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"] diff --git a/apps/complex/serializers.py b/apps/complex/serializers.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/complex/services/__init__.py b/apps/complex/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/complex/tasks.py b/apps/complex/tasks.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/complex/templates/complex/.gitkeep b/apps/complex/templates/complex/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/complex/tests/__init__.py b/apps/complex/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/complex/urls.py b/apps/complex/urls.py new file mode 100644 index 0000000..98ca7b1 --- /dev/null +++ b/apps/complex/urls.py @@ -0,0 +1,5 @@ +from django.urls import path + +app_name = "complex" + +urlpatterns: list = [] diff --git a/apps/complex/views.py b/apps/complex/views.py new file mode 100644 index 0000000..e69de29 diff --git a/core/enums.py b/core/enums.py index 1c5a87e..de7dfc8 100644 --- a/core/enums.py +++ b/core/enums.py @@ -352,6 +352,20 @@ class ComplexPhotoCategory(models.TextChoices): OTHER = "other", "其他" +class ComplexPropertyUsageType(models.TextChoices): + RESIDENTIAL = "residential", "住宅" + VILLA = "villa", "别墅" + COMMERCIAL_RESIDENTIAL = "commercial_residential", "商住" + COMMERCIAL = "commercial", "商业" + OFFICE = "office", "写字楼" + OTHER = "other", "其他" + + +class ComplexBuildingStructure(models.TextChoices): + UNIT_ROOM = "unit_room", "单元-房号" + OTHER = "other", "其他" + + # ────────────────────────────────────────────────────────────── # 3.5 property # ──────────────────────────────────────────────────────────────